= [];
+
+ constructor() {
+ super();
+ const now = DateTime.now();
+ this.#timeZoneList = getTimeZoneList(undefined).map((tz) => ({
+ ...tz,
+ offset: getTimeZoneOffset(tz.value, now), // Format offset as string
+ }));
+
+ this.addValidator(
+ 'rangeUnderflow',
+ () => this.localize.term('validation_entriesShort', this.min, this.min - this.value.length),
+ () => this.value.length < this.min,
+ );
+
+ this.addValidator(
+ 'rangeOverflow',
+ () => this.localize.term('validation_entriesExceed', this.max, this.value.length - (this.max || 0)),
+ () => !!this.max && this.value.length > this.max,
+ );
+ }
+
+ protected override getFormElement() {
+ return undefined;
+ }
+
+ #onAdd() {
+ if (this.#timeZonePicker) {
+ this.value = [...this.value, this.#timeZonePicker.value];
+ this.#timeZonePicker.value = '';
+ this._timeZonePickerValue = '';
+ }
+ this.pristine = false;
+ this.dispatchEvent(new UmbChangeEvent());
+ }
+
+ #onDelete(itemIndex: number) {
+ this.value = this.value.filter((_item, index) => index !== itemIndex);
+ this.pristine = false;
+ this.dispatchEvent(new UmbChangeEvent());
+ }
+
+ override render() {
+ return html`${this.#renderSelectedItems()}
+ ${this.#renderAddTimeZone()}`;
+ }
+
+ #renderSelectedItems() {
+ return html`
+ ${repeat(
+ this.value,
+ (item) => item,
+ (item, index) => html`
+ this.#onDelete(index)}>
+
+ `,
+ )}
+ `;
+ }
+
+ #renderAddTimeZone() {
+ if (this.disabled || this.readonly) return nothing;
+ return html`
+
+ !this.value.includes(tz.value))}
+ @change=${(event: UmbChangeEvent) => {
+ const target = event.target as UmbInputTimeZonePickerElement;
+ this._timeZonePickerValue = target?.value;
+ }}
+ ?disabled=${this.disabled}
+ ?readonly=${this.readonly}
+ ${ref(this.#onTimeZonePickerRefChanged)}>
+
+ ${when(
+ !this.readonly,
+ () => html`
+
+
+
+ `,
+ )}
+
+ `;
+ }
+
+ #onTimeZonePickerRefChanged(input?: Element) {
+ if (this.#timeZonePicker) {
+ this.removeFormControlElement(this.#timeZonePicker);
+ }
+ this.#timeZonePicker = input as UmbInputTimeZonePickerElement | undefined;
+ if (this.#timeZonePicker) {
+ this.addFormControlElement(this.#timeZonePicker);
+ }
+ }
+
+ static override styles = [
+ css`
+ #add-time-zone {
+ display: flex;
+ margin-bottom: var(--uui-size-space-3);
+ gap: var(--uui-size-space-1);
+ }
+
+ #time-zone-picker {
+ width: 100%;
+ display: inline-flex;
+ --uui-input-height: var(--uui-size-12);
+ }
+
+ .--umb-sorter-placeholder {
+ position: relative;
+ visibility: hidden;
+ }
+ .--umb-sorter-placeholder::after {
+ content: '';
+ position: absolute;
+ inset: 0px;
+ border-radius: var(--uui-border-radius);
+ border: 1px dashed var(--uui-color-divider-emphasis);
+ }
+ `,
+ ];
+}
+
+export default UmbInputTimeZoneElement;
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'umb-input-time-zone': UmbInputTimeZoneElement;
+ }
+}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/date/date.timezone.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/date/date.timezone.ts
new file mode 100644
index 0000000000..00eb69b387
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/date/date.timezone.ts
@@ -0,0 +1,85 @@
+import type { DateTime } from '@umbraco-cms/backoffice/external/luxon';
+
+export interface UmbTimeZone {
+ value: string;
+ name: string;
+}
+
+/**
+ * Retrieves a list of supported time zones in the browser.
+ * @param {Array} [filter] - An optional array of time zone identifiers to filter the result on.
+ * @returns {Array} An array of objects containing time zone values and names.
+ */
+export function getTimeZoneList(filter: Array | undefined = undefined): Array {
+ if (filter) {
+ return filter.map((tz) => ({
+ value: tz,
+ name: getTimeZoneName(tz),
+ }));
+ }
+
+ const timeZones = Intl.supportedValuesOf('timeZone')
+ // Exclude offset time zones, e.g. 'Etc/GMT+2', as they are not consistent between browsers
+ .filter((value) => value !== 'UTC' && !value.startsWith('Etc/'));
+
+ // Add UTC to the top of the list
+ timeZones.unshift('UTC');
+
+ return timeZones.map((tz) => ({
+ value: tz,
+ name: getTimeZoneName(tz),
+ }));
+}
+
+/**
+ * Retrieves the client's time zone information.
+ * @param {DateTime} [selectedDate] - An optional Luxon DateTime object to format the offset of the time zone.
+ * @returns {UmbTimeZone} An object containing the client's time zone name and value.
+ */
+export function getClientTimeZone(): UmbTimeZone {
+ const clientTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
+ return {
+ value: clientTimeZone,
+ name: getTimeZoneName(clientTimeZone),
+ };
+}
+
+/**
+ * Returns the time zone offset for a given time zone ID and date.
+ * @param timeZoneId - The time zone identifier (e.g., 'America/New_York').
+ * @param date - The Luxon DateTime object for which to get the offset.
+ * @returns {string} The time zone offset
+ */
+export function getTimeZoneOffset(timeZoneId: string, date: DateTime): string {
+ return date.setZone(timeZoneId).toFormat('Z');
+}
+
+/**
+ * Returns the browser's time zone name in a user-friendly format.
+ * @param {string} timeZoneId - The time zone identifier.
+ * @returns {string} A formatted time zone name.
+ */
+export function getTimeZoneName(timeZoneId: string) {
+ if (timeZoneId === 'UTC') {
+ return 'Coordinated Universal Time (UTC)';
+ }
+
+ return timeZoneId.replaceAll('_', ' ');
+}
+
+/**
+ * Checks if two time zone identifiers are equivalent.
+ * This function compares the resolved time zone names to determine if they are equivalent.
+ * @param {string} tz1 - The first time zone identifier.
+ * @param {string} tz2 - The second time zone identifier.
+ * @returns {boolean} True if the time zones are equivalent, false otherwise.
+ */
+export function isEquivalentTimeZone(tz1: string, tz2: string): boolean {
+ if (tz1 === tz2) {
+ return true;
+ }
+
+ const tz1Name = new Intl.DateTimeFormat(undefined, { timeZone: tz1 }).resolvedOptions().timeZone;
+ const tz2Name = new Intl.DateTimeFormat(undefined, { timeZone: tz2 }).resolvedOptions().timeZone;
+ return tz1Name === tz2Name;
+}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/date/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/date/index.ts
new file mode 100644
index 0000000000..75529f7da3
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/date/index.ts
@@ -0,0 +1 @@
+export * from './date.timezone.js';
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/index.ts
index 1fbed7af00..33deb40984 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/utils/index.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/index.ts
@@ -1,5 +1,6 @@
export * from './array/index.js';
export * from './bytes/bytes.function.js';
+export * from './date/index.js';
export * from './debounce/debounce.function.js';
export * from './deprecation/index.js';
export * from './diff/index.js';
diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-only-picker/Umbraco.DateOnly.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-only-picker/Umbraco.DateOnly.ts
new file mode 100644
index 0000000000..ba8e29f0d1
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-only-picker/Umbraco.DateOnly.ts
@@ -0,0 +1,10 @@
+import type { ManifestPropertyEditorSchema } from '@umbraco-cms/backoffice/property-editor';
+
+export const manifest: ManifestPropertyEditorSchema = {
+ type: 'propertyEditorSchema',
+ name: 'Date Only',
+ alias: 'Umbraco.DateOnly',
+ meta: {
+ defaultPropertyEditorUiAlias: 'Umb.PropertyEditorUi.DateOnlyPicker',
+ },
+};
diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-only-picker/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-only-picker/manifests.ts
new file mode 100644
index 0000000000..c42c8a78e8
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-only-picker/manifests.ts
@@ -0,0 +1,18 @@
+import { manifest as schemaManifest } from './Umbraco.DateOnly.js';
+
+export const manifests: Array = [
+ {
+ type: 'propertyEditorUi',
+ alias: 'Umb.PropertyEditorUi.DateOnlyPicker',
+ name: 'Date Only Picker Property Editor UI',
+ element: () => import('./property-editor-ui-date-only-picker.element.js'),
+ meta: {
+ label: 'Date Only',
+ propertyEditorSchemaAlias: 'Umbraco.DateOnly',
+ icon: 'icon-calendar-alt',
+ group: 'date',
+ supportsReadOnly: true,
+ },
+ },
+ schemaManifest,
+];
diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-only-picker/property-editor-ui-date-only-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-only-picker/property-editor-ui-date-only-picker.element.ts
new file mode 100644
index 0000000000..8f7e4ec538
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-only-picker/property-editor-ui-date-only-picker.element.ts
@@ -0,0 +1,20 @@
+import { UmbPropertyEditorUiDateTimePickerElementBase } from '../property-editor-ui-date-time-picker-base.js';
+import { customElement } from '@umbraco-cms/backoffice/external/lit';
+
+/**
+ * @element umb-property-editor-ui-date-only-picker
+ */
+@customElement('umb-property-editor-ui-date-only-picker')
+export class UmbPropertyEditorUIDateOnlyPickerElement extends UmbPropertyEditorUiDateTimePickerElementBase {
+ constructor() {
+ super('date', false);
+ }
+}
+
+export default UmbPropertyEditorUIDateOnlyPickerElement;
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'umb-property-editor-ui-date-only-picker': UmbPropertyEditorUIDateOnlyPickerElement;
+ }
+}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-picker/Umbraco.DateTimeUnspecified.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-picker/Umbraco.DateTimeUnspecified.ts
new file mode 100644
index 0000000000..d237b807df
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-picker/Umbraco.DateTimeUnspecified.ts
@@ -0,0 +1,10 @@
+import type { ManifestPropertyEditorSchema } from '@umbraco-cms/backoffice/property-editor';
+
+export const manifest: ManifestPropertyEditorSchema = {
+ type: 'propertyEditorSchema',
+ name: 'Date Time (unspecified)',
+ alias: 'Umbraco.DateTimeUnspecified',
+ meta: {
+ defaultPropertyEditorUiAlias: 'Umb.PropertyEditorUi.DateTimePicker',
+ },
+};
diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-picker/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-picker/manifests.ts
new file mode 100644
index 0000000000..af73ef37f6
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-picker/manifests.ts
@@ -0,0 +1,42 @@
+import { manifest as schemaManifest } from './Umbraco.DateTimeUnspecified.js';
+
+export const manifests: Array = [
+ {
+ type: 'propertyEditorUi',
+ alias: 'Umb.PropertyEditorUi.DateTimePicker',
+ name: 'Date Time Picker Property Editor UI',
+ element: () => import('./property-editor-ui-date-time-picker.element.js'),
+ meta: {
+ label: 'Date Time (unspecified)',
+ propertyEditorSchemaAlias: 'Umbraco.DateTimeUnspecified',
+ icon: 'icon-calendar-alt',
+ group: 'date',
+ supportsReadOnly: true,
+ settings: {
+ properties: [
+ {
+ alias: 'timeFormat',
+ label: '#dateTimePicker_config_timeFormat',
+ propertyEditorUiAlias: 'Umb.PropertyEditorUi.RadioButtonList',
+ config: [
+ {
+ alias: 'items',
+ value: [
+ { name: 'HH:mm', value: 'HH:mm' },
+ { name: 'HH:mm:ss', value: 'HH:mm:ss' },
+ ],
+ },
+ ],
+ },
+ ],
+ defaultData: [
+ {
+ alias: 'timeFormat',
+ value: 'HH:mm',
+ },
+ ],
+ },
+ },
+ },
+ schemaManifest,
+];
diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-picker/property-editor-ui-date-time-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-picker/property-editor-ui-date-time-picker.element.ts
new file mode 100644
index 0000000000..d3a9bd4c24
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-picker/property-editor-ui-date-time-picker.element.ts
@@ -0,0 +1,20 @@
+import { UmbPropertyEditorUiDateTimePickerElementBase } from '../property-editor-ui-date-time-picker-base.js';
+import { customElement } from '@umbraco-cms/backoffice/external/lit';
+
+/**
+ * @element umb-property-editor-ui-date-time-picker
+ */
+@customElement('umb-property-editor-ui-date-time-picker')
+export class UmbPropertyEditorUIDateTimePickerElement extends UmbPropertyEditorUiDateTimePickerElementBase {
+ constructor() {
+ super('datetime-local', false);
+ }
+}
+
+export default UmbPropertyEditorUIDateTimePickerElement;
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'umb-property-editor-ui-date-time-picker': UmbPropertyEditorUIDateTimePickerElement;
+ }
+}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-with-time-zone-picker/Umbraco.DateTimeWithTimeZone.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-with-time-zone-picker/Umbraco.DateTimeWithTimeZone.ts
new file mode 100644
index 0000000000..03fa0988c1
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-with-time-zone-picker/Umbraco.DateTimeWithTimeZone.ts
@@ -0,0 +1,10 @@
+import type { ManifestPropertyEditorSchema } from '@umbraco-cms/backoffice/property-editor';
+
+export const manifest: ManifestPropertyEditorSchema = {
+ type: 'propertyEditorSchema',
+ name: 'Date Time (with time zone)',
+ alias: 'Umbraco.DateTimeWithTimeZone',
+ meta: {
+ defaultPropertyEditorUiAlias: 'Umb.PropertyEditorUi.DateTimeWithTimeZonePicker',
+ },
+};
diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-with-time-zone-picker/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-with-time-zone-picker/manifests.ts
new file mode 100644
index 0000000000..d514e0b9eb
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-with-time-zone-picker/manifests.ts
@@ -0,0 +1,56 @@
+import { manifest as schemaManifest } from './Umbraco.DateTimeWithTimeZone.js';
+
+export const manifests: Array = [
+ {
+ type: 'propertyEditorUi',
+ alias: 'Umb.PropertyEditorUi.DateTimeWithTimeZonePicker',
+ name: 'Date Time with Time Zone Picker Property Editor UI',
+ element: () => import('./property-editor-ui-date-time-with-time-zone-picker.element.js'),
+ meta: {
+ label: 'Date Time (with time zone)',
+ propertyEditorSchemaAlias: 'Umbraco.DateTimeWithTimeZone',
+ icon: 'icon-calendar-alt',
+ group: 'date',
+ supportsReadOnly: true,
+ settings: {
+ properties: [
+ {
+ alias: 'timeFormat',
+ label: '#dateTimePicker_config_timeFormat',
+ propertyEditorUiAlias: 'Umb.PropertyEditorUi.RadioButtonList',
+ config: [
+ {
+ alias: 'items',
+ value: [
+ { name: 'HH:mm', value: 'HH:mm' },
+ { name: 'HH:mm:ss', value: 'HH:mm:ss' },
+ ],
+ },
+ ],
+ },
+ {
+ alias: 'timeZones',
+ label: '#dateTimePicker_config_timeZones',
+ description: '{#dateTimePicker_config_timeZones_description}',
+ propertyEditorUiAlias: 'Umb.PropertyEditorUi.TimeZonePicker',
+ config: [],
+ },
+ ],
+ defaultData: [
+ {
+ alias: 'timeFormat',
+ value: 'HH:mm',
+ },
+ {
+ alias: 'timeZones',
+ value: {
+ mode: 'all',
+ timeZones: [],
+ },
+ },
+ ],
+ },
+ },
+ },
+ schemaManifest,
+];
diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-with-time-zone-picker/property-editor-ui-date-time-with-time-zone-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-with-time-zone-picker/property-editor-ui-date-time-with-time-zone-picker.element.ts
new file mode 100644
index 0000000000..f87c7144b2
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-with-time-zone-picker/property-editor-ui-date-time-with-time-zone-picker.element.ts
@@ -0,0 +1,20 @@
+import { UmbPropertyEditorUiDateTimePickerElementBase } from '../property-editor-ui-date-time-picker-base.js';
+import { customElement } from '@umbraco-cms/backoffice/external/lit';
+
+/**
+ * @element umb-property-editor-ui-date-time-with-time-zone-picker
+ */
+@customElement('umb-property-editor-ui-date-time-with-time-zone-picker')
+export class UmbPropertyEditorUIDateTimeWithTimeZonePickerElement extends UmbPropertyEditorUiDateTimePickerElementBase {
+ constructor() {
+ super('datetime-local', true);
+ }
+}
+
+export default UmbPropertyEditorUIDateTimeWithTimeZonePickerElement;
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'umb-property-editor-ui-date-time-with-time-zone-picker': UmbPropertyEditorUIDateTimeWithTimeZonePickerElement;
+ }
+}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/manifests.ts
new file mode 100644
index 0000000000..ab7ceaa4d9
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/manifests.ts
@@ -0,0 +1,11 @@
+import { manifests as dateTimeManifests } from './date-time-picker/manifests.js';
+import { manifests as dateTimeWithTimeZoneManifests } from './date-time-with-time-zone-picker/manifests.js';
+import { manifests as dateOnlyManifests } from './date-only-picker/manifests.js';
+import { manifests as timeOnlyManifests } from './time-only-picker/manifests.js';
+
+export const manifests: Array = [
+ ...dateTimeManifests,
+ ...dateTimeWithTimeZoneManifests,
+ ...dateOnlyManifests,
+ ...timeOnlyManifests,
+];
diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/property-editor-ui-date-time-picker-base.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/property-editor-ui-date-time-picker-base.ts
new file mode 100644
index 0000000000..8c0b1ce9aa
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/property-editor-ui-date-time-picker-base.ts
@@ -0,0 +1,446 @@
+import type { UmbTimeZonePickerValue } from '../time-zone-picker/property-editor-ui-time-zone-picker.element.js';
+import type { InputDateType, UmbInputDateElement } from '@umbraco-cms/backoffice/components';
+import { css, html, nothing, property, repeat, state, until } from '@umbraco-cms/backoffice/external/lit';
+import { DateTime } from '@umbraco-cms/backoffice/external/luxon';
+import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
+import type {
+ UmbPropertyEditorConfigCollection,
+ UmbPropertyEditorUiElement,
+} from '@umbraco-cms/backoffice/property-editor';
+import {
+ getClientTimeZone,
+ getTimeZoneList,
+ getTimeZoneOffset,
+ isEquivalentTimeZone,
+ type UmbTimeZone,
+} from '@umbraco-cms/backoffice/utils';
+import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation';
+import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
+import type { UUIComboboxElement, UUIComboboxEvent } from '@umbraco-cms/backoffice/external/uui';
+
+interface UmbDateTime {
+ date: string | undefined;
+ timeZone: string | undefined;
+}
+
+interface UmbTimeZonePickerOption extends UmbTimeZone {
+ offset: string;
+ invalid: boolean;
+}
+
+export abstract class UmbPropertyEditorUiDateTimePickerElementBase
+ extends UmbFormControlMixin(UmbLitElement)
+ implements UmbPropertyEditorUiElement
+{
+ private _timeZoneOptions: Array = [];
+ private _clientTimeZone: UmbTimeZone | undefined;
+
+ @property({ type: Boolean, reflect: true })
+ readonly = false;
+
+ @property({ type: Boolean })
+ mandatory?: boolean;
+
+ @property({ type: String })
+ mandatoryMessage?: string | undefined;
+
+ @state()
+ protected _dateInputType: InputDateType = 'datetime-local';
+
+ @state()
+ protected _dateInputFormat: string = 'yyyy-MM-dd HH:mm:ss';
+
+ @state()
+ private _dateInputStep: number = 1;
+
+ @state()
+ private _selectedDate?: DateTime;
+
+ @state()
+ private _datePickerValue: string = '';
+
+ @state()
+ private _filteredTimeZoneOptions: Array = [];
+
+ @state()
+ protected _displayTimeZone: boolean = true;
+
+ @state()
+ private _selectedTimeZone: string | undefined;
+
+ constructor(dateInputType: InputDateType, displayTimeZone: boolean) {
+ super();
+
+ this._dateInputType = dateInputType;
+ switch (dateInputType) {
+ case 'date':
+ this._dateInputFormat = 'yyyy-MM-dd';
+ break;
+ case 'time':
+ this._dateInputFormat = 'HH:mm:ss';
+ break;
+ }
+ this._displayTimeZone = displayTimeZone;
+
+ this.addValidator(
+ 'customError',
+ () => this.localize.term('dateTimePicker_emptyDate'),
+ () => {
+ return !!this.mandatory && !this.value?.date;
+ },
+ );
+
+ this.addValidator(
+ 'customError',
+ () => this.localize.term('dateTimePicker_emptyTimeZone'),
+ () => {
+ return !!this.value?.date && this._displayTimeZone && !this.value.timeZone;
+ },
+ );
+
+ this.addValidator(
+ 'customError',
+ () => this.localize.term('dateTimePicker_invalidTimeZone'),
+ () => {
+ return (
+ this._displayTimeZone &&
+ !!this.value?.timeZone &&
+ !this._timeZoneOptions.some((opt) => opt.value === this.value?.timeZone && !opt.invalid)
+ );
+ },
+ );
+ }
+
+ public set config(config: UmbPropertyEditorConfigCollection | undefined) {
+ if (!config) return;
+
+ const timeFormat = config.getValueByAlias('timeFormat');
+ let timeZonePickerConfig: UmbTimeZonePickerValue | undefined = undefined;
+
+ if (this._displayTimeZone) {
+ timeZonePickerConfig = config.getValueByAlias('timeZones');
+ }
+ this.#setTimeInputStep(timeFormat);
+ this.#prefillValue(timeZonePickerConfig);
+ }
+
+ #prefillValue(timeZonePickerConfig: UmbTimeZonePickerValue | undefined) {
+ const date = this.value?.date;
+ const zone = this.value?.timeZone;
+
+ if (!date) {
+ if (timeZonePickerConfig) {
+ // If the date is not provided, we prefill the time zones using the current date (used to retrieve the offsets)
+ this.#prefillTimeZones(timeZonePickerConfig, DateTime.now());
+ }
+ return;
+ }
+
+ // Load the date from the value
+ const dateTime = DateTime.fromISO(date, { zone: zone ?? 'UTC' }); // If no zone is provided, we default to UTC.
+ if (!dateTime.isValid) {
+ if (timeZonePickerConfig) {
+ // If the date is invalid, we prefill the time zones using the current date (used to retrieve the offsets)
+ this.#prefillTimeZones(timeZonePickerConfig, DateTime.now());
+ }
+ console.warn(`[UmbPropertyEditorUIDateTimePickerElement] Invalid date format: ${date}`);
+ return;
+ }
+
+ this._selectedDate = dateTime;
+ this._datePickerValue = dateTime.toFormat(this._dateInputFormat);
+
+ if (timeZonePickerConfig) {
+ this.#prefillTimeZones(timeZonePickerConfig, dateTime);
+ }
+ }
+
+ #prefillTimeZones(config: UmbTimeZonePickerValue | undefined, selectedDate: DateTime | undefined) {
+ // Retrieve the time zones from the config
+ this._clientTimeZone = getClientTimeZone();
+
+ // Retrieve the time zones from the config
+ const dateToCalculateOffset = selectedDate ?? DateTime.now();
+ switch (config?.mode) {
+ case 'all':
+ this._timeZoneOptions = this._filteredTimeZoneOptions = getTimeZoneList(undefined).map((tz) => ({
+ ...tz,
+ offset: getTimeZoneOffset(tz.value, dateToCalculateOffset),
+ invalid: false,
+ }));
+ break;
+ case 'local': {
+ this._timeZoneOptions = this._filteredTimeZoneOptions = [this._clientTimeZone].map((tz) => ({
+ ...tz,
+ offset: getTimeZoneOffset(tz.value, dateToCalculateOffset),
+ invalid: false,
+ }));
+ break;
+ }
+ case 'custom': {
+ this._timeZoneOptions = this._filteredTimeZoneOptions = getTimeZoneList(config.timeZones).map((tz) => ({
+ ...tz,
+ offset: getTimeZoneOffset(tz.value, dateToCalculateOffset),
+ invalid: false,
+ }));
+ const selectedTimeZone = this.value?.timeZone;
+ if (
+ selectedTimeZone &&
+ !this._timeZoneOptions.some((opt) => isEquivalentTimeZone(opt.value, selectedTimeZone))
+ ) {
+ // If the selected time zone is not in the list, we add it to the options
+ const customTimeZone: UmbTimeZonePickerOption = {
+ value: selectedTimeZone,
+ name: selectedTimeZone,
+ offset: getTimeZoneOffset(selectedTimeZone, dateToCalculateOffset),
+ invalid: true, // Mark as invalid, as it is not in the list of supported time zones
+ };
+ this._timeZoneOptions.push(customTimeZone);
+ }
+ break;
+ }
+ default:
+ return;
+ }
+
+ this.#preselectTimeZone();
+ }
+
+ #preselectTimeZone() {
+ // Check whether there is a time zone in the value (stored previously)
+ const selectedTimezone = this.value?.timeZone;
+ if (selectedTimezone) {
+ const pickedTimeZone = this._timeZoneOptions.find(
+ // A time zone name can be different in different browsers, so we need extra logic to match the client name with the options
+ (option) => isEquivalentTimeZone(option.value, selectedTimezone),
+ );
+ if (pickedTimeZone) {
+ this._selectedTimeZone = pickedTimeZone.value;
+ return;
+ }
+ } else if (this.value?.date) {
+ return; // If there is a date but no time zone, we don't preselect anything
+ }
+
+ // Check if we can pre-select the client time zone
+ const clientTimeZone = this._clientTimeZone;
+ const clientTimeZoneOpt =
+ clientTimeZone &&
+ this._timeZoneOptions.find(
+ // A time zone name can be different in different browsers, so we need extra logic to match the client name with the options
+ (option) => isEquivalentTimeZone(option.value, clientTimeZone.value),
+ );
+ if (clientTimeZoneOpt) {
+ this._selectedTimeZone = clientTimeZoneOpt.value;
+ if (this._selectedDate) {
+ this._selectedDate = this._selectedDate.setZone(clientTimeZone.value);
+ this._datePickerValue = this._selectedDate.toFormat(this._dateInputFormat);
+ }
+ return;
+ }
+
+ // If no time zone was selected still, we can default to the first option
+ const firstOption = this._timeZoneOptions[0]?.value;
+ this._selectedTimeZone = firstOption;
+ if (this._selectedDate) {
+ this._selectedDate = this._selectedDate.setZone(firstOption);
+ this._datePickerValue = this._selectedDate.toFormat(this._dateInputFormat);
+ }
+ }
+
+ #setTimeInputStep(timeFormat: string | undefined) {
+ switch (timeFormat) {
+ case 'HH:mm':
+ this._dateInputStep = 60; // 1 hour
+ break;
+ case 'HH:mm:ss':
+ this._dateInputStep = 1; // 1 second
+ break;
+ default:
+ this._dateInputStep = 1;
+ break;
+ }
+ }
+
+ #onValueChange(event: CustomEvent & { target: UmbInputDateElement }) {
+ const value = event.target.value.toString();
+ const newPickerValue = value.replace('T', ' ');
+ if (newPickerValue === this._datePickerValue) {
+ return;
+ }
+
+ if (!newPickerValue) {
+ this._datePickerValue = '';
+ this.value = undefined;
+ this._selectedDate = undefined;
+ this.dispatchEvent(new UmbChangeEvent());
+ return;
+ }
+
+ this._datePickerValue = newPickerValue;
+ this.#updateValue(value, true);
+ }
+
+ #onTimeZoneChange(event: UUIComboboxEvent) {
+ const timeZoneValue = (event.target as UUIComboboxElement).value.toString();
+ if (timeZoneValue === this._selectedTimeZone) {
+ return; // No change in time zone selection
+ }
+
+ this._selectedTimeZone = timeZoneValue;
+
+ if (!this._selectedTimeZone) {
+ if (this.value?.date) {
+ this.value = { date: this.value.date, timeZone: undefined };
+ } else {
+ this.value = undefined;
+ }
+ this.dispatchEvent(new UmbChangeEvent());
+ return;
+ }
+
+ if (!this._selectedDate) {
+ return;
+ }
+
+ this.#updateValue(this._selectedDate.toISO({ includeOffset: false }) || '');
+ }
+
+ #updateValue(date: string, updateOffsets = false) {
+ // Try to parse the date with the selected time zone
+ const newDate = DateTime.fromISO(date, { zone: this._selectedTimeZone ?? 'UTC' });
+
+ // If the date is invalid, we reset the value
+ if (!newDate.isValid) {
+ this.value = undefined;
+ this._selectedDate = undefined;
+ this.dispatchEvent(new UmbChangeEvent());
+ return;
+ }
+
+ this._selectedDate = newDate;
+ this.value = {
+ date: this.#getCurrentDateValue(),
+ timeZone: this._selectedTimeZone,
+ };
+
+ if (updateOffsets) {
+ this._timeZoneOptions.forEach((opt) => {
+ opt.offset = getTimeZoneOffset(opt.value, newDate);
+ });
+ // Update the time zone options (mostly for the offset)
+ this._filteredTimeZoneOptions = this._timeZoneOptions;
+ }
+ this.dispatchEvent(new UmbChangeEvent());
+ }
+
+ #getCurrentDateValue(): string | undefined {
+ switch (this._dateInputType) {
+ case 'date':
+ return this._selectedDate?.toISODate() ?? undefined;
+ case 'time':
+ return this._selectedDate?.toISOTime({ includeOffset: false }) ?? undefined;
+ default:
+ return this._selectedDate?.toISO({ includeOffset: !!this._selectedTimeZone }) ?? undefined;
+ }
+ }
+
+ #onTimeZoneSearch(event: UUIComboboxEvent) {
+ const searchTerm = (event.target as UUIComboboxElement)?.search;
+ this._filteredTimeZoneOptions = this._timeZoneOptions.filter(
+ (option) => option.name.toLowerCase().includes(searchTerm.toLowerCase()) || option.offset === searchTerm,
+ );
+ }
+
+ override render() {
+ return html`
+
+
+
+ ${this.#renderTimeZones()}
+
+ ${this.#renderTimeZoneInfo()}
+ `;
+ }
+
+ #renderTimeZones() {
+ if (!this._displayTimeZone || this._timeZoneOptions.length === 0) {
+ return nothing;
+ }
+
+ if (this._timeZoneOptions.length === 1) {
+ return html`${this._timeZoneOptions[0].name} ${this._timeZoneOptions[0].value ===
+ this._clientTimeZone?.value
+ ? ` (${this.localize.term('dateTimePicker_local')})`
+ : nothing}`;
+ }
+
+ return html`
+
+
+ ${until(repeat(this._filteredTimeZoneOptions, this.#renderTimeZoneOption))}
+
+
+ `;
+ }
+
+ #renderTimeZoneOption = (option: UmbTimeZonePickerOption) =>
+ html`
+ ${option.name + (option.invalid ? ` (${this.localize.term('validation_legacyOption')})` : '')}
+ `;
+
+ #renderTimeZoneInfo() {
+ if (
+ this._timeZoneOptions.length === 0 ||
+ !this._selectedTimeZone ||
+ !this._selectedDate ||
+ this._selectedTimeZone === this._clientTimeZone?.value
+ ) {
+ return nothing;
+ }
+
+ return html` ${this.localize.term(
+ 'dateTimePicker_differentTimeZoneLabel',
+ `UTC${this._selectedDate.toFormat('Z')}`,
+ this._selectedDate.toLocal().toFormat('ff'),
+ )}`;
+ }
+
+ static override readonly styles = [
+ css`
+ :host {
+ display: flex;
+ flex-direction: column;
+ }
+ .picker {
+ display: flex;
+ align-items: center;
+ gap: var(--uui-size-space-2);
+ }
+ .info {
+ color: var(--uui-color-text-alt);
+ font-size: var(--uui-type-small-size);
+ font-weight: normal;
+ }
+ .error {
+ color: var(--uui-color-invalid);
+ font-size: var(--uui-font-size-small);
+ }
+ `,
+ ];
+}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/property-editor-ui-date-time-picker.test.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/property-editor-ui-date-time-picker.test.ts
new file mode 100644
index 0000000000..8cc2816813
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/property-editor-ui-date-time-picker.test.ts
@@ -0,0 +1,79 @@
+import { expect, fixture, html } from '@open-wc/testing';
+import { type UmbTestRunnerWindow, defaultA11yConfig } from '@umbraco-cms/internal/test-utils';
+import UmbPropertyEditorUIDateTimePickerElement from './date-time-picker/property-editor-ui-date-time-picker.element.js';
+import UmbPropertyEditorUIDateOnlyPickerElement from './date-only-picker/property-editor-ui-date-only-picker.element.js';
+import UmbPropertyEditorUITimeOnlyPickerElement from './time-only-picker/property-editor-ui-time-only-picker.element.js';
+import UmbPropertyEditorUIDateTimeWithTimeZonePickerElement from './date-time-with-time-zone-picker/property-editor-ui-date-time-with-time-zone-picker.element.js';
+
+describe('UmbPropertyEditorUIDateTimePickerElement', () => {
+ let element: UmbPropertyEditorUIDateTimePickerElement;
+
+ beforeEach(async () => {
+ element = await fixture(html` `);
+ });
+
+ it('is defined with its own instance', () => {
+ expect(element).to.be.instanceOf(UmbPropertyEditorUIDateTimePickerElement);
+ });
+
+ if ((window as UmbTestRunnerWindow).__UMBRACO_TEST_RUN_A11Y_TEST) {
+ it('passes the a11y audit', async () => {
+ await expect(element).shadowDom.to.be.accessible(defaultA11yConfig);
+ });
+ }
+});
+
+describe('UmbPropertyEditorUIDateTimeWithTimeZonePickerElement', () => {
+ let element: UmbPropertyEditorUIDateTimeWithTimeZonePickerElement;
+
+ beforeEach(async () => {
+ element = await fixture(html` `);
+ });
+
+ it('is defined with its own instance', () => {
+ expect(element).to.be.instanceOf(UmbPropertyEditorUIDateTimeWithTimeZonePickerElement);
+ });
+
+ if ((window as UmbTestRunnerWindow).__UMBRACO_TEST_RUN_A11Y_TEST) {
+ it('passes the a11y audit', async () => {
+ await expect(element).shadowDom.to.be.accessible(defaultA11yConfig);
+ });
+ }
+});
+
+
+describe('UmbPropertyEditorUIDateOnlyPickerElement', () => {
+ let element: UmbPropertyEditorUIDateOnlyPickerElement;
+
+ beforeEach(async () => {
+ element = await fixture(html` `);
+ });
+
+ it('is defined with its own instance', () => {
+ expect(element).to.be.instanceOf(UmbPropertyEditorUIDateOnlyPickerElement);
+ });
+
+ if ((window as UmbTestRunnerWindow).__UMBRACO_TEST_RUN_A11Y_TEST) {
+ it('passes the a11y audit', async () => {
+ await expect(element).shadowDom.to.be.accessible(defaultA11yConfig);
+ });
+ }
+});
+
+describe('UmbPropertyEditorUITimeOnlyPickerElement', () => {
+ let element: UmbPropertyEditorUITimeOnlyPickerElement;
+
+ beforeEach(async () => {
+ element = await fixture(html` `);
+ });
+
+ it('is defined with its own instance', () => {
+ expect(element).to.be.instanceOf(UmbPropertyEditorUITimeOnlyPickerElement);
+ });
+
+ if ((window as UmbTestRunnerWindow).__UMBRACO_TEST_RUN_A11Y_TEST) {
+ it('passes the a11y audit', async () => {
+ await expect(element).shadowDom.to.be.accessible(defaultA11yConfig);
+ });
+ }
+});
diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/time-only-picker/Umbraco.TimeOnly.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/time-only-picker/Umbraco.TimeOnly.ts
new file mode 100644
index 0000000000..3e6d6015d5
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/time-only-picker/Umbraco.TimeOnly.ts
@@ -0,0 +1,10 @@
+import type { ManifestPropertyEditorSchema } from '@umbraco-cms/backoffice/property-editor';
+
+export const manifest: ManifestPropertyEditorSchema = {
+ type: 'propertyEditorSchema',
+ name: 'Time Only',
+ alias: 'Umbraco.TimeOnly',
+ meta: {
+ defaultPropertyEditorUiAlias: 'Umb.PropertyEditorUi.TimeOnlyPicker',
+ },
+};
diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/time-only-picker/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/time-only-picker/manifests.ts
new file mode 100644
index 0000000000..b06aca52f2
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/time-only-picker/manifests.ts
@@ -0,0 +1,42 @@
+import { manifest as schemaManifest } from './Umbraco.TimeOnly.js';
+
+export const manifests: Array = [
+ {
+ type: 'propertyEditorUi',
+ alias: 'Umb.PropertyEditorUi.TimeOnlyPicker',
+ name: 'Time Only Picker Property Editor UI',
+ element: () => import('./property-editor-ui-time-only-picker.element.js'),
+ meta: {
+ label: 'Time Only',
+ propertyEditorSchemaAlias: 'Umbraco.TimeOnly',
+ icon: 'icon-time',
+ group: 'date',
+ supportsReadOnly: true,
+ settings: {
+ properties: [
+ {
+ alias: 'timeFormat',
+ label: '#dateTimePicker_config_timeFormat',
+ propertyEditorUiAlias: 'Umb.PropertyEditorUi.RadioButtonList',
+ config: [
+ {
+ alias: 'items',
+ value: [
+ { name: 'HH:mm', value: 'HH:mm' },
+ { name: 'HH:mm:ss', value: 'HH:mm:ss' },
+ ],
+ },
+ ],
+ },
+ ],
+ defaultData: [
+ {
+ alias: 'timeFormat',
+ value: 'HH:mm',
+ },
+ ],
+ },
+ },
+ },
+ schemaManifest,
+];
diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/time-only-picker/property-editor-ui-time-only-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/time-only-picker/property-editor-ui-time-only-picker.element.ts
new file mode 100644
index 0000000000..76d7b4b011
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/time-only-picker/property-editor-ui-time-only-picker.element.ts
@@ -0,0 +1,20 @@
+import { UmbPropertyEditorUiDateTimePickerElementBase } from '../property-editor-ui-date-time-picker-base.js';
+import { customElement } from '@umbraco-cms/backoffice/external/lit';
+
+/**
+ * @element umb-property-editor-ui-time-only-picker
+ */
+@customElement('umb-property-editor-ui-time-only-picker')
+export class UmbPropertyEditorUITimeOnlyPickerElement extends UmbPropertyEditorUiDateTimePickerElementBase {
+ constructor() {
+ super('time', false);
+ }
+}
+
+export default UmbPropertyEditorUITimeOnlyPickerElement;
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'umb-property-editor-ui-time-only-picker': UmbPropertyEditorUITimeOnlyPickerElement;
+ }
+}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/manifests.ts
index a4c4dcf504..948ce907b6 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/manifests.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/manifests.ts
@@ -6,10 +6,12 @@ import { manifest as orderDirection } from './order-direction/manifests.js';
import { manifest as overlaySize } from './overlay-size/manifests.js';
import { manifest as select } from './select/manifests.js';
import { manifest as valueType } from './value-type/manifests.js';
+import { manifest as timeZonePicker } from './time-zone-picker/manifests.js';
import { manifests as checkboxListManifests } from './checkbox-list/manifests.js';
import { manifests as collectionManifests } from './collection/manifests.js';
import { manifests as colorPickerManifests } from './color-picker/manifests.js';
import { manifests as datePickerManifests } from './date-picker/manifests.js';
+import { manifests as dateTimeManifests } from './date-time/manifests.js';
import { manifests as dropdownManifests } from './dropdown/manifests.js';
import { manifests as eyeDropperManifests } from './eye-dropper/manifests.js';
import { manifests as iconPickerManifests } from './icon-picker/manifests.js';
@@ -29,6 +31,7 @@ export const manifests: Array = [
...collectionManifests,
...colorPickerManifests,
...datePickerManifests,
+ ...dateTimeManifests,
...dropdownManifests,
...eyeDropperManifests,
...iconPickerManifests,
@@ -50,4 +53,5 @@ export const manifests: Array = [
overlaySize,
select,
valueType,
+ timeZonePicker,
];
diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/time-zone-picker/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/time-zone-picker/manifests.ts
new file mode 100644
index 0000000000..0a244d874b
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/time-zone-picker/manifests.ts
@@ -0,0 +1,13 @@
+import type { ManifestPropertyEditorUi } from '@umbraco-cms/backoffice/property-editor';
+
+export const manifest: ManifestPropertyEditorUi = {
+ type: 'propertyEditorUi',
+ alias: 'Umb.PropertyEditorUi.TimeZonePicker',
+ name: 'Time Zone Picker Property Editor UI',
+ element: () => import('./property-editor-ui-time-zone-picker.element.js'),
+ meta: {
+ label: 'Time Zone Picker',
+ icon: 'icon-globe',
+ group: '',
+ },
+};
diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/time-zone-picker/property-editor-ui-time-zone-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/time-zone-picker/property-editor-ui-time-zone-picker.element.ts
new file mode 100644
index 0000000000..579735e3ee
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/time-zone-picker/property-editor-ui-time-zone-picker.element.ts
@@ -0,0 +1,141 @@
+import { html, customElement, property, css, map, ref } from '@umbraco-cms/backoffice/external/lit';
+import type {
+ UmbPropertyEditorUiElement,
+ UmbPropertyEditorConfigCollection,
+} from '@umbraco-cms/backoffice/property-editor';
+import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
+import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
+import type { UUIRadioEvent } from '@umbraco-cms/backoffice/external/uui';
+import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation';
+import type { UmbInputTimeZoneElement } from '@umbraco-cms/backoffice/components';
+
+export interface UmbTimeZonePickerValue {
+ mode: string;
+ timeZones: Array;
+}
+
+/**
+ * @element umb-property-editor-ui-time-zone-picker
+ */
+@customElement('umb-property-editor-ui-time-zone-picker')
+export class UmbPropertyEditorUITimeZonePickerElement
+ extends UmbFormControlMixin(UmbLitElement)
+ implements UmbPropertyEditorUiElement
+{
+ private _supportedModes = ['all', 'local', 'custom'];
+ private _selectedTimeZones: Array = [];
+
+ override set value(value: UmbTimeZonePickerValue | undefined) {
+ super.value = value;
+ this._selectedTimeZones = value?.timeZones ?? [];
+ }
+
+ override get value(): UmbTimeZonePickerValue | undefined {
+ return super.value;
+ }
+
+ @property({ type: Boolean, reflect: true })
+ readonly: boolean = false;
+
+ @property({ attribute: false })
+ public config?: UmbPropertyEditorConfigCollection;
+
+ #inputTimeZone?: UmbInputTimeZoneElement;
+
+ constructor() {
+ super();
+
+ this.addValidator(
+ 'valueMissing',
+ () => UMB_VALIDATION_EMPTY_LOCALIZATION_KEY,
+ () => !this.value?.mode || this._supportedModes.indexOf(this.value.mode) === -1,
+ );
+ }
+
+ #onModeInput(event: UUIRadioEvent) {
+ if (!this._supportedModes.includes(event.target.value)) throw new Error(`Unknown mode: ${event.target.value}`);
+ this.value = {
+ mode: event.target.value,
+ timeZones: this.#inputTimeZone?.value ? Array.from(this.#inputTimeZone?.value) : [],
+ };
+ this.dispatchEvent(new UmbChangeEvent());
+ }
+
+ #onChange(event: UmbChangeEvent) {
+ const target = event.target as UmbInputTimeZoneElement;
+ const selectedOptions = target.value;
+
+ if (this.value?.mode === 'custom') {
+ this.value = { mode: this.value.mode, timeZones: selectedOptions };
+ } else {
+ this.value = { mode: this.value?.mode ?? 'all', timeZones: [] };
+ }
+
+ this.dispatchEvent(new UmbChangeEvent());
+ }
+
+ #inputTimeZoneRefChanged(input?: Element) {
+ if (this.#inputTimeZone) {
+ this.removeFormControlElement(this.#inputTimeZone);
+ }
+ this.#inputTimeZone = input as UmbInputTimeZoneElement | undefined;
+ if (this.#inputTimeZone) {
+ this.addFormControlElement(this.#inputTimeZone);
+ }
+ }
+
+ override render() {
+ return html`
+
+ ${map(
+ this._supportedModes,
+ (mode) =>
+ html``,
+ )}
+
+
+
+
+
+ `;
+ }
+
+ static override readonly styles = [
+ css`
+ :host {
+ display: grid;
+ gap: var(--uui-size-space-3);
+ }
+
+ .timezone-picker {
+ display: flex;
+ flex-direction: row;
+ gap: var(--uui-size-space-6);
+ }
+
+ .timezone-picker[hidden] {
+ display: none;
+ }
+ `,
+ ];
+}
+
+export default UmbPropertyEditorUITimeZonePickerElement;
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'umb-property-editor-ui-time-zone-picker': UmbPropertyEditorUITimeZonePickerElement;
+ }
+}
diff --git a/src/Umbraco.Web.UI.Client/tsconfig.json b/src/Umbraco.Web.UI.Client/tsconfig.json
index 078dbfa93a..eebc431a10 100644
--- a/src/Umbraco.Web.UI.Client/tsconfig.json
+++ b/src/Umbraco.Web.UI.Client/tsconfig.json
@@ -157,6 +157,7 @@ DON'T EDIT THIS FILE DIRECTLY. It is generated by /devops/tsconfig/index.js
"@umbraco-cms/backoffice/external/dompurify": ["./src/external/dompurify/index.ts"],
"@umbraco-cms/backoffice/external/heximal-expressions": ["./src/external/heximal-expressions/index.ts"],
"@umbraco-cms/backoffice/external/lit": ["./src/external/lit/index.ts"],
+ "@umbraco-cms/backoffice/external/luxon": ["./src/external/luxon/index.ts"],
"@umbraco-cms/backoffice/external/marked": ["./src/external/marked/index.ts"],
"@umbraco-cms/backoffice/external/monaco-editor": ["./src/external/monaco-editor/index.ts"],
"@umbraco-cms/backoffice/external/openid": ["./src/external/openid/index.ts"],
diff --git a/tests/Umbraco.Tests.Common/Builders/ContentBuilder.cs b/tests/Umbraco.Tests.Common/Builders/ContentBuilder.cs
index 2767d26ce3..2a00680ab7 100644
--- a/tests/Umbraco.Tests.Common/Builders/ContentBuilder.cs
+++ b/tests/Umbraco.Tests.Common/Builders/ContentBuilder.cs
@@ -464,6 +464,7 @@ public class ContentBuilder
content.SetValue("memberPicker", Udi.Create(Constants.UdiEntityType.Member, new Guid("9A50A448-59C0-4D42-8F93-4F1D55B0F47D")).ToString());
content.SetValue("multiUrlPicker", "[{\"name\":\"https://test.com\",\"url\":\"https://test.com\"}]");
content.SetValue("tags", "this,is,tags");
+ content.SetValue("dateTimeWithTimeZone", "{\"date\":\"2025-01-22T18:33:01.0000000+01:00\",\"timeZone\":\"Europe/Copenhagen\"}");
return content;
}
diff --git a/tests/Umbraco.Tests.Common/Builders/ContentTypeBuilder.cs b/tests/Umbraco.Tests.Common/Builders/ContentTypeBuilder.cs
index 54e9090ddb..633604f1ba 100644
--- a/tests/Umbraco.Tests.Common/Builders/ContentTypeBuilder.cs
+++ b/tests/Umbraco.Tests.Common/Builders/ContentTypeBuilder.cs
@@ -531,6 +531,14 @@ public class ContentTypeBuilder
.WithValueStorageType(ValueStorageType.Ntext)
.WithSortOrder(20)
.Done()
+ .AddPropertyType()
+ .WithAlias("dateTimeWithTimeZone")
+ .WithName("Date Time (with time zone)")
+ .WithDataTypeId(1055)
+ .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.DateTimeWithTimeZone)
+ .WithValueStorageType(ValueStorageType.Ntext)
+ .WithSortOrder(21)
+ .Done()
.Done()
.Build();
}
diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs
index 784638e594..0160d95fcf 100644
--- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs
+++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs
@@ -2735,6 +2735,9 @@ internal sealed class ContentServiceTests : UmbracoIntegrationTestWithContent
Assert.That(sut.GetValue("multiUrlPicker"),
Is.EqualTo("[{\"name\":\"https://test.com\",\"url\":\"https://test.com\"}]"));
Assert.That(sut.GetValue("tags"), Is.EqualTo("this,is,tags"));
+ Assert.That(
+ sut.GetValue("dateTimeWithTimeZone"),
+ Is.EqualTo("{\"date\":\"2025-01-22T18:33:01.0000000+01:00\",\"timeZone\":\"Europe/Copenhagen\"}"));
}
[Test]
diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DataTypeDefinitionRepositoryTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DataTypeDefinitionRepositoryTest.cs
index 4ff77c86fa..7b2f3c3cc9 100644
--- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DataTypeDefinitionRepositoryTest.cs
+++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DataTypeDefinitionRepositoryTest.cs
@@ -250,7 +250,7 @@ internal sealed class DataTypeDefinitionRepositoryTest : UmbracoIntegrationTest
Assert.That(dataTypeDefinitions, Is.Not.Null);
Assert.That(dataTypeDefinitions.Any(), Is.True);
Assert.That(dataTypeDefinitions.Any(x => x == null), Is.False);
- Assert.That(dataTypeDefinitions.Length, Is.EqualTo(36));
+ Assert.That(dataTypeDefinitions.Length, Is.EqualTo(37));
}
}
@@ -297,7 +297,7 @@ internal sealed class DataTypeDefinitionRepositoryTest : UmbracoIntegrationTest
var count = DataTypeRepository.Count(query);
// Assert
- Assert.That(count, Is.EqualTo(4));
+ Assert.That(count, Is.EqualTo(5));
}
}
diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorTests.cs
new file mode 100644
index 0000000000..7a1a5d0dac
--- /dev/null
+++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorTests.cs
@@ -0,0 +1,138 @@
+using System.Text.Json.Nodes;
+using NUnit.Framework;
+using Umbraco.Cms.Core;
+using Umbraco.Cms.Core.Cache;
+using Umbraco.Cms.Core.Models;
+using Umbraco.Cms.Core.Notifications;
+using Umbraco.Cms.Core.PropertyEditors;
+using Umbraco.Cms.Core.PublishedCache;
+using Umbraco.Cms.Core.Services;
+using Umbraco.Cms.Core.Sync;
+using Umbraco.Cms.Infrastructure.HybridCache;
+using Umbraco.Cms.Tests.Common.Builders;
+using Umbraco.Cms.Tests.Common.Builders.Extensions;
+using Umbraco.Cms.Tests.Common.Testing;
+using Umbraco.Cms.Tests.Integration.Testing;
+using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services;
+
+namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.PropertyEditors;
+
+[TestFixture]
+[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)]
+public class DateTimePropertyEditorTests : UmbracoIntegrationTest
+{
+ private IDataTypeService DataTypeService => GetRequiredService();
+
+ private IContentTypeService ContentTypeService => GetRequiredService();
+
+ private IContentEditingService ContentEditingService => GetRequiredService();
+
+ private IContentPublishingService ContentPublishingService => GetRequiredService();
+
+ private IPublishedContentCache PublishedContentCache => GetRequiredService();
+
+ protected override void CustomTestSetup(IUmbracoBuilder builder)
+ {
+ builder.AddNotificationHandler();
+ builder.Services.AddUnique();
+ }
+
+ private static readonly object[] _sourceList1 =
+ [
+ new object[] { Constants.PropertyEditors.Aliases.DateOnly, false, new DateOnly(2025, 1, 22) },
+ new object[] { Constants.PropertyEditors.Aliases.TimeOnly, false, new TimeOnly(18, 33, 1) },
+ new object[] { Constants.PropertyEditors.Aliases.DateTimeUnspecified, false, new DateTime(2025, 1, 22, 18, 33, 1) },
+ new object[] { Constants.PropertyEditors.Aliases.DateTimeWithTimeZone, true, new DateTimeOffset(2025, 1, 22, 18, 33, 1, TimeSpan.Zero) },
+ ];
+
+ [TestCaseSource(nameof(_sourceList1))]
+ public async Task Returns_Correct_Type_Based_On_Configuration(
+ string editorAlias,
+ bool timeZone,
+ object expectedValue)
+ {
+ var dataType = new DataTypeBuilder()
+ .WithId(0)
+ .WithDatabaseType(ValueStorageType.Ntext)
+ .AddEditor()
+ .WithAlias(editorAlias)
+ .WithConfigurationEditor(
+ new DateTimeConfigurationEditor(IOHelper)
+ {
+ DefaultConfiguration = new Dictionary
+ {
+ ["timeFormat"] = "HH:mm",
+ ["timeZones"] = timeZone ? new { mode = "all" } : null,
+ },
+ })
+ .WithDefaultConfiguration(
+ new Dictionary
+ {
+ ["timeFormat"] = "HH:mm",
+ ["timeZones"] = timeZone ? new { mode = "all" } : null,
+ })
+ .Done()
+ .Build();
+
+ var dataTypeCreateResult = await DataTypeService.CreateAsync(dataType, Constants.Security.SuperUserKey);
+ Assert.IsTrue(dataTypeCreateResult.Success);
+
+ var contentType = new ContentTypeBuilder()
+ .WithAlias("contentType")
+ .WithName("Content Type")
+ .WithAllowAsRoot(true)
+ .AddPropertyGroup()
+ .WithAlias("content")
+ .WithName("Content")
+ .WithSupportsPublishing(true)
+ .AddPropertyType()
+ .WithAlias("dateTime")
+ .WithName("Date Time")
+ .WithDataTypeId(dataTypeCreateResult.Result.Id)
+ .Done()
+ .Done()
+ .Build();
+
+ var contentTypeCreateResult = await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey);
+ Assert.IsTrue(contentTypeCreateResult.Success);
+
+ var content = new ContentEditingBuilder()
+ .WithContentTypeKey(contentType.Key)
+ .AddVariant()
+ .WithName("My Content")
+ .Done()
+ .AddProperty()
+ .WithAlias("dateTime")
+ .WithValue(
+ new JsonObject
+ {
+ ["date"] = "2025-01-22T18:33:01.0000000+00:00",
+ ["timeZone"] = "Europe/Copenhagen",
+ })
+ .Done()
+ .Build();
+ var createContentResult = await ContentEditingService.CreateAsync(content, Constants.Security.SuperUserKey);
+ Assert.IsTrue(createContentResult.Success);
+ Assert.IsNotNull(createContentResult.Result.Content);
+ var dateTimeProperty = createContentResult.Result.Content.Properties["dateTime"];
+ Assert.IsNotNull(dateTimeProperty, "After content creation, the property should exist");
+ Assert.IsNotNull(dateTimeProperty.GetValue(), "After content creation, the property value should not be null");
+
+ var publishResult = await ContentPublishingService.PublishBranchAsync(
+ createContentResult.Result.Content.Key,
+ [],
+ PublishBranchFilter.IncludeUnpublished,
+ Constants.Security.SuperUserKey,
+ false);
+
+ Assert.IsTrue(publishResult.Success);
+
+ var test = ((DocumentCache)PublishedContentCache).GetAtRoot(false);
+ var publishedContent = await PublishedContentCache.GetByIdAsync(createContentResult.Result.Content.Key, false);
+ Assert.IsNotNull(publishedContent);
+
+ var value = publishedContent.GetProperty("dateTime")?.GetValue();
+ Assert.IsNotNull(value);
+ Assert.AreEqual(expectedValue, value);
+ }
+}
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeLoaderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeLoaderTests.cs
index 4987432aea..253fcc2b4b 100644
--- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeLoaderTests.cs
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeLoaderTests.cs
@@ -132,7 +132,7 @@ public class TypeLoaderTests
public void GetDataEditors()
{
var types = _typeLoader.GetDataEditors();
- Assert.AreEqual(37, types.Count());
+ Assert.AreEqual(41, types.Count());
}
///
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DateOnlyPropertyEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DateOnlyPropertyEditorTests.cs
new file mode 100644
index 0000000000..1738df2590
--- /dev/null
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DateOnlyPropertyEditorTests.cs
@@ -0,0 +1,164 @@
+using System.Globalization;
+using System.Text.Json.Nodes;
+using Microsoft.Extensions.Logging;
+using Moq;
+using NUnit.Framework;
+using Umbraco.Cms.Core;
+using Umbraco.Cms.Core.IO;
+using Umbraco.Cms.Core.Models;
+using Umbraco.Cms.Core.Models.Editors;
+using Umbraco.Cms.Core.Models.Validation;
+using Umbraco.Cms.Core.PropertyEditors;
+using Umbraco.Cms.Core.PropertyEditors.ValueConverters;
+using Umbraco.Cms.Core.Serialization;
+using Umbraco.Cms.Core.Services;
+using Umbraco.Cms.Core.Strings;
+using Umbraco.Cms.Infrastructure.Models;
+using Umbraco.Cms.Infrastructure.Serialization;
+
+namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors;
+
+[TestFixture]
+public class DateOnlyPropertyEditorTests
+{
+ private static readonly IJsonSerializer _jsonSerializer =
+ new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory());
+
+ private static readonly object[] _validateDateReceivedTestCases =
+ [
+ new object[] { null, true },
+ new object[] { JsonNode.Parse("{}"), false },
+ new object[] { JsonNode.Parse("{\"test\": \"\"}"), false },
+ new object[] { JsonNode.Parse("{\"date\": \"\"}"), false },
+ new object[] { JsonNode.Parse("{\"date\": \"INVALID\"}"), false },
+ new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T14:30:00\"}"), true }
+ ];
+
+ [TestCaseSource(nameof(_validateDateReceivedTestCases))]
+ public void Validates_Date_Received(object? value, bool expectedSuccess)
+ {
+ var editor = CreateValueEditor();
+ var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()).ToList();
+ if (expectedSuccess)
+ {
+ Assert.IsEmpty(result);
+ }
+ else
+ {
+ Assert.AreEqual(1, result.Count);
+
+ var validationResult = result.First();
+ Assert.AreEqual("validation_invalidDate", validationResult.ErrorMessage);
+ }
+ }
+
+ private static readonly object[] _dateOnlyParseValuesFromEditorTestCases =
+ [
+ new object[] { null, null, null },
+ new object[] { JsonNode.Parse("{\"date\": \"2025-08-20\"}"), new DateTimeOffset(2025, 8, 20, 0, 0, 0, TimeSpan.Zero), null },
+ ];
+
+ [TestCaseSource(nameof(_dateOnlyParseValuesFromEditorTestCases))]
+ public void Can_Parse_Values_From_Editor(
+ object? value,
+ DateTimeOffset? expectedDateTimeOffset,
+ string? expectedTimeZone)
+ {
+ var expectedJson = expectedDateTimeOffset is null ? null : _jsonSerializer.Serialize(
+ new DateTimeValueConverterBase.DateTimeDto
+ {
+ Date = expectedDateTimeOffset.Value,
+ TimeZone = expectedTimeZone,
+ });
+ var result = CreateValueEditor().FromEditor(
+ new ContentPropertyData(
+ value,
+ new DateTimeConfiguration
+ {
+ TimeZones = null,
+ }),
+ null);
+ Assert.AreEqual(expectedJson, result);
+ }
+
+ private static readonly object[][] _dateOnlyParseValuesToEditorTestCases =
+ [
+ [null, null, null],
+ [0, null, new DateTimeEditorValue { Date = "2025-08-20", TimeZone = null }],
+ ];
+
+ [TestCaseSource(nameof(_dateOnlyParseValuesToEditorTestCases))]
+ public void Can_Parse_Values_To_Editor(
+ int? offset,
+ string? timeZone,
+ object? expectedResult)
+ {
+ var storedValue = offset is null
+ ? null
+ : new DateTimeValueConverterBase.DateTimeDto
+ {
+ Date = new DateTimeOffset(2025, 8, 20, 16, 30, 00, TimeSpan.FromHours(offset.Value)),
+ TimeZone = timeZone,
+ };
+ var valueEditor = CreateValueEditor(timeZoneMode: null);
+ var storedValueJson = storedValue is null ? null : _jsonSerializer.Serialize(storedValue);
+ var result = valueEditor.ToEditor(
+ new Property(new PropertyType(Mock.Of(), "dataType", ValueStorageType.Ntext))
+ {
+ Values =
+ [
+ new Property.PropertyValue
+ {
+ EditedValue = storedValueJson,
+ PublishedValue = storedValueJson,
+ }
+ ],
+ });
+
+ if (expectedResult is null)
+ {
+ Assert.IsNull(result);
+ return;
+ }
+
+ Assert.IsNotNull(result);
+ Assert.IsInstanceOf(result);
+ var apiModel = (DateTimeEditorValue)result;
+ Assert.AreEqual(((DateTimeEditorValue)expectedResult).Date, apiModel.Date);
+ Assert.AreEqual(((DateTimeEditorValue)expectedResult).TimeZone, apiModel.TimeZone);
+ }
+
+ private DateTimePropertyEditorBase.DateTimeDataValueEditor CreateValueEditor(
+ DateTimeConfiguration.TimeZoneMode? timeZoneMode = null,
+ string[]? timeZones = null)
+ {
+ var localizedTextServiceMock = new Mock();
+ localizedTextServiceMock.Setup(x => x.Localize(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>()))
+ .Returns((string key, string alias, CultureInfo _, IDictionary _) => $"{key}_{alias}");
+ var valueEditor = new DateTimePropertyEditorBase.DateTimeDataValueEditor(
+ Mock.Of(),
+ _jsonSerializer,
+ Mock.Of(),
+ new DataEditorAttribute(Constants.PropertyEditors.Aliases.DateOnly),
+ localizedTextServiceMock.Object,
+ Mock.Of>(),
+ dt => dt.Date.ToString("yyyy-MM-dd"))
+ {
+ ConfigurationObject = new DateTimeConfiguration
+ {
+ TimeZones = timeZoneMode is null
+ ? null
+ : new DateTimeConfiguration.TimeZonesConfiguration
+ {
+ Mode = timeZoneMode.Value,
+ TimeZones = timeZones?.ToList() ?? [],
+ },
+ },
+ };
+ return valueEditor;
+ }
+}
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DateTimeUnspecifiedPropertyEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DateTimeUnspecifiedPropertyEditorTests.cs
new file mode 100644
index 0000000000..726ea51157
--- /dev/null
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DateTimeUnspecifiedPropertyEditorTests.cs
@@ -0,0 +1,167 @@
+using System.Globalization;
+using System.Text.Json.Nodes;
+using Microsoft.Extensions.Logging;
+using Moq;
+using NUnit.Framework;
+using Umbraco.Cms.Core;
+using Umbraco.Cms.Core.IO;
+using Umbraco.Cms.Core.Models;
+using Umbraco.Cms.Core.Models.Editors;
+using Umbraco.Cms.Core.Models.Validation;
+using Umbraco.Cms.Core.PropertyEditors;
+using Umbraco.Cms.Core.PropertyEditors.ValueConverters;
+using Umbraco.Cms.Core.Serialization;
+using Umbraco.Cms.Core.Services;
+using Umbraco.Cms.Core.Strings;
+using Umbraco.Cms.Infrastructure.Models;
+using Umbraco.Cms.Infrastructure.Serialization;
+
+namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors;
+
+[TestFixture]
+public class DateTimeUnspecifiedPropertyEditorTests
+{
+ private static readonly IJsonSerializer _jsonSerializer =
+ new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory());
+
+ private static readonly object[] _validateDateReceivedTestCases =
+ [
+ new object[] { null, true },
+ new object[] { JsonNode.Parse("{}"), false },
+ new object[] { JsonNode.Parse("{\"test\": \"\"}"), false },
+ new object[] { JsonNode.Parse("{\"date\": \"\"}"), false },
+ new object[] { JsonNode.Parse("{\"date\": \"INVALID\"}"), false },
+ new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T14:30:00\"}"), true }
+ ];
+
+ [TestCaseSource(nameof(_validateDateReceivedTestCases))]
+ public void Validates_Date_Received(object? value, bool expectedSuccess)
+ {
+ var editor = CreateValueEditor();
+ var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()).ToList();
+ if (expectedSuccess)
+ {
+ Assert.IsEmpty(result);
+ }
+ else
+ {
+ Assert.AreEqual(1, result.Count);
+
+ var validationResult = result.First();
+ Assert.AreEqual("validation_invalidDate", validationResult.ErrorMessage);
+ }
+ }
+
+ private static readonly object[] _dateTimeUnspecifiedParseValuesFromEditorTestCases =
+ [
+ new object[] { null, null, null },
+ new object[] { JsonNode.Parse("{}"), null, null },
+ new object[] { JsonNode.Parse("{\"INVALID\": \"\"}"), null, null },
+ new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T18:30:01\"}"), new DateTimeOffset(2025, 8, 20, 18, 30, 1, TimeSpan.Zero), null },
+ new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T18:30:01Z\"}"), new DateTimeOffset(2025, 8, 20, 18, 30, 1, TimeSpan.Zero), null },
+ new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T18:30:01-05:00\"}"), new DateTimeOffset(2025, 8, 20, 18, 30, 1, TimeSpan.FromHours(-5)), null },
+ ];
+
+ [TestCaseSource(nameof(_dateTimeUnspecifiedParseValuesFromEditorTestCases))]
+ public void Can_Parse_Values_From_Editor(
+ object? value,
+ DateTimeOffset? expectedDateTimeOffset,
+ string? expectedTimeZone)
+ {
+ var expectedJson = expectedDateTimeOffset is null ? null : _jsonSerializer.Serialize(
+ new DateTimeValueConverterBase.DateTimeDto
+ {
+ Date = expectedDateTimeOffset.Value,
+ TimeZone = expectedTimeZone,
+ });
+ var result = CreateValueEditor().FromEditor(
+ new ContentPropertyData(
+ value,
+ new DateTimeConfiguration
+ {
+ TimeZones = null,
+ }),
+ null);
+ Assert.AreEqual(expectedJson, result);
+ }
+
+ private static readonly object[][] _dateTimeUnspecifiedParseValuesToEditorTestCases =
+ [
+ [null, null],
+ [0, new DateTimeEditorValue { Date = "2025-08-20T16:30:00", TimeZone = null }],
+ [2, new DateTimeEditorValue { Date = "2025-08-20T16:30:00", TimeZone = null }],
+ ];
+
+ [TestCaseSource(nameof(_dateTimeUnspecifiedParseValuesToEditorTestCases))]
+ public void Can_Parse_Values_To_Editor(
+ int? offset,
+ object? expectedResult)
+ {
+ var storedValue = offset is null
+ ? null
+ : new DateTimeValueConverterBase.DateTimeDto
+ {
+ Date = new DateTimeOffset(2025, 8, 20, 16, 30, 00, TimeSpan.FromHours(offset.Value)),
+ };
+ var valueEditor = CreateValueEditor(timeZoneMode: null);
+ var storedValueJson = storedValue is null ? null : _jsonSerializer.Serialize(storedValue);
+ var result = valueEditor.ToEditor(
+ new Property(new PropertyType(Mock.Of(), "dataType", ValueStorageType.Ntext))
+ {
+ Values =
+ [
+ new Property.PropertyValue
+ {
+ EditedValue = storedValueJson,
+ PublishedValue = storedValueJson,
+ }
+ ],
+ });
+
+ if (expectedResult is null)
+ {
+ Assert.IsNull(result);
+ return;
+ }
+
+ Assert.IsNotNull(result);
+ Assert.IsInstanceOf(result);
+ var apiModel = (DateTimeEditorValue)result;
+ Assert.AreEqual(((DateTimeEditorValue)expectedResult).Date, apiModel.Date);
+ Assert.AreEqual(((DateTimeEditorValue)expectedResult).TimeZone, apiModel.TimeZone);
+ }
+
+ private DateTimePropertyEditorBase.DateTimeDataValueEditor CreateValueEditor(
+ DateTimeConfiguration.TimeZoneMode? timeZoneMode = null,
+ string[]? timeZones = null)
+ {
+ var localizedTextServiceMock = new Mock();
+ localizedTextServiceMock.Setup(x => x.Localize(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>()))
+ .Returns((string key, string alias, CultureInfo _, IDictionary _) => $"{key}_{alias}");
+ var valueEditor = new DateTimePropertyEditorBase.DateTimeDataValueEditor(
+ Mock.Of(),
+ _jsonSerializer,
+ Mock.Of(),
+ new DataEditorAttribute(Constants.PropertyEditors.Aliases.DateTimeUnspecified),
+ localizedTextServiceMock.Object,
+ Mock.Of>(),
+ dt => dt.Date.ToString("yyyy-MM-ddTHH:mm:ss"))
+ {
+ ConfigurationObject = new DateTimeConfiguration
+ {
+ TimeZones = timeZoneMode is null
+ ? null
+ : new DateTimeConfiguration.TimeZonesConfiguration
+ {
+ Mode = timeZoneMode.Value,
+ TimeZones = timeZones?.ToList() ?? [],
+ },
+ },
+ };
+ return valueEditor;
+ }
+}
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DateTimeWithTimeZonePropertyEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DateTimeWithTimeZonePropertyEditorTests.cs
new file mode 100644
index 0000000000..601cc8a472
--- /dev/null
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DateTimeWithTimeZonePropertyEditorTests.cs
@@ -0,0 +1,205 @@
+using System.Globalization;
+using System.Text.Json.Nodes;
+using Microsoft.Extensions.Logging;
+using Moq;
+using NUnit.Framework;
+using Umbraco.Cms.Core;
+using Umbraco.Cms.Core.IO;
+using Umbraco.Cms.Core.Models;
+using Umbraco.Cms.Core.Models.Editors;
+using Umbraco.Cms.Core.Models.Validation;
+using Umbraco.Cms.Core.PropertyEditors;
+using Umbraco.Cms.Core.PropertyEditors.ValueConverters;
+using Umbraco.Cms.Core.Serialization;
+using Umbraco.Cms.Core.Services;
+using Umbraco.Cms.Core.Strings;
+using Umbraco.Cms.Infrastructure.Models;
+using Umbraco.Cms.Infrastructure.Serialization;
+
+namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors;
+
+[TestFixture]
+public class DateTimeWithTimeZonePropertyEditorTests
+{
+ private static readonly IJsonSerializer _jsonSerializer =
+ new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory());
+
+ private static readonly object[] _validateDateReceivedTestCases =
+ [
+ new object[] { null, true },
+ new object[] { JsonNode.Parse("{}"), false },
+ new object[] { JsonNode.Parse("{\"test\": \"\"}"), false },
+ new object[] { JsonNode.Parse("{\"date\": \"\"}"), false },
+ new object[] { JsonNode.Parse("{\"date\": \"INVALID\"}"), false },
+ new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T14:30:00\"}"), true }
+ ];
+
+ private static readonly object[] _sourceList2 =
+ [
+ new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T14:30:00\"}"), DateTimeConfiguration.TimeZoneMode.All, Array.Empty(), true },
+ new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T14:30:00\"}"), DateTimeConfiguration.TimeZoneMode.Local, Array.Empty(), true },
+ new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T14:30:00\"}"), DateTimeConfiguration.TimeZoneMode.Custom, new[] { "Europe/Copenhagen" }, false },
+ new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T14:30:00\", \"timeZone\": \"Europe/Copenhagen\"}"), DateTimeConfiguration.TimeZoneMode.All, Array.Empty(), true },
+ new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T14:30:00\", \"timeZone\": \"Europe/Copenhagen\"}"), DateTimeConfiguration.TimeZoneMode.Local, Array.Empty(), true },
+ new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T14:30:00\", \"timeZone\": \"Europe/Copenhagen\"}"), DateTimeConfiguration.TimeZoneMode.Custom, Array.Empty(), false },
+ new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T14:30:00\", \"timeZone\": \"Europe/Copenhagen\"}"), DateTimeConfiguration.TimeZoneMode.Custom, new[] { "Europe/Copenhagen" }, true },
+ new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T14:30:00\", \"timeZone\": \"Europe/Copenhagen\"}"), DateTimeConfiguration.TimeZoneMode.Custom, new[] { "Europe/Amsterdam", "Europe/Copenhagen" }, true },
+ new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T14:30:00\", \"timeZone\": \"Europe/Copenhagen\"}"), DateTimeConfiguration.TimeZoneMode.Custom, new[] { "Europe/Amsterdam" }, false },
+ ];
+
+ [TestCaseSource(nameof(_sourceList2))]
+ public void Validates_TimeZone_Received(
+ object value,
+ DateTimeConfiguration.TimeZoneMode timeZoneMode,
+ string[] timeZones,
+ bool expectedSuccess)
+ {
+ var editor = CreateValueEditor(timeZoneMode: timeZoneMode, timeZones: timeZones);
+ var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()).ToList();
+ if (expectedSuccess)
+ {
+ Assert.IsEmpty(result);
+ }
+ else
+ {
+ Assert.AreEqual(1, result.Count);
+
+ var validationResult = result.First();
+ Assert.AreEqual("validation_notOneOfOptions", validationResult.ErrorMessage);
+ }
+ }
+
+ [TestCaseSource(nameof(_validateDateReceivedTestCases))]
+ public void Validates_Date_Received(object? value, bool expectedSuccess)
+ {
+ var editor = CreateValueEditor();
+ var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()).ToList();
+ if (expectedSuccess)
+ {
+ Assert.IsEmpty(result);
+ }
+ else
+ {
+ Assert.AreEqual(1, result.Count);
+
+ var validationResult = result.First();
+ Assert.AreEqual("validation_invalidDate", validationResult.ErrorMessage);
+ }
+ }
+
+ private static readonly object[] _dateTimeWithTimeZoneParseValuesFromEditorTestCases =
+ [
+ new object[] { null, null, null },
+ new object[] { JsonNode.Parse("{}"), null, null },
+ new object[] { JsonNode.Parse("{\"INVALID\": \"\"}"), null, null },
+ new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T18:30:01\"}"), new DateTimeOffset(2025, 8, 20, 18, 30, 1, TimeSpan.Zero), null },
+ new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T18:30:01Z\"}"), new DateTimeOffset(2025, 8, 20, 18, 30, 1, TimeSpan.Zero), null },
+ new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T18:30:01-05:00\"}"), new DateTimeOffset(2025, 8, 20, 18, 30, 1, TimeSpan.FromHours(-5)), null },
+ ];
+
+ [TestCaseSource(nameof(_dateTimeWithTimeZoneParseValuesFromEditorTestCases))]
+ public void Can_Parse_Values_From_Editor(
+ object? value,
+ DateTimeOffset? expectedDateTimeOffset,
+ string? expectedTimeZone)
+ {
+ var expectedJson = expectedDateTimeOffset is null ? null : _jsonSerializer.Serialize(
+ new DateTimeValueConverterBase.DateTimeDto
+ {
+ Date = expectedDateTimeOffset.Value,
+ TimeZone = expectedTimeZone,
+ });
+ var result = CreateValueEditor().FromEditor(
+ new ContentPropertyData(
+ value,
+ new DateTimeConfiguration
+ {
+ TimeZones = null,
+ }),
+ null);
+ Assert.AreEqual(expectedJson, result);
+ }
+
+ private static readonly object[][] _dateTimeWithTimeZoneParseValuesToEditorTestCases =
+ [
+ [null, null, DateTimeConfiguration.TimeZoneMode.All, null],
+ [0, null, DateTimeConfiguration.TimeZoneMode.All, new DateTimeEditorValue { Date = "2025-08-20T16:30:00+00:00", TimeZone = null }],
+ [0, null, DateTimeConfiguration.TimeZoneMode.Local, new DateTimeEditorValue { Date = "2025-08-20T16:30:00+00:00", TimeZone = null }],
+ [0, null, DateTimeConfiguration.TimeZoneMode.Custom, new DateTimeEditorValue { Date = "2025-08-20T16:30:00+00:00", TimeZone = null }],
+ [-5, "Europe/Copenhagen", DateTimeConfiguration.TimeZoneMode.All, new DateTimeEditorValue { Date = "2025-08-20T16:30:00-05:00", TimeZone = "Europe/Copenhagen" }],
+ ];
+
+ [TestCaseSource(nameof(_dateTimeWithTimeZoneParseValuesToEditorTestCases))]
+ public void Can_Parse_Values_To_Editor(
+ int? offset,
+ string? timeZone,
+ DateTimeConfiguration.TimeZoneMode timeZoneMode,
+ object? expectedResult)
+ {
+ var storedValue = offset is null
+ ? null
+ : new DateTimeValueConverterBase.DateTimeDto
+ {
+ Date = new DateTimeOffset(2025, 8, 20, 16, 30, 00, TimeSpan.FromHours(offset.Value)),
+ TimeZone = timeZone,
+ };
+ var valueEditor = CreateValueEditor(timeZoneMode: timeZoneMode);
+ var storedValueJson = storedValue is null ? null : _jsonSerializer.Serialize(storedValue);
+ var result = valueEditor.ToEditor(
+ new Property(new PropertyType(Mock.Of(), "dataType", ValueStorageType.Ntext))
+ {
+ Values =
+ [
+ new Property.PropertyValue
+ {
+ EditedValue = storedValueJson,
+ PublishedValue = storedValueJson,
+ }
+ ],
+ });
+
+ if (expectedResult is null)
+ {
+ Assert.IsNull(result);
+ return;
+ }
+
+ Assert.IsNotNull(result);
+ Assert.IsInstanceOf(result);
+ var apiModel = (DateTimeEditorValue)result;
+ Assert.AreEqual(((DateTimeEditorValue)expectedResult).Date, apiModel.Date);
+ Assert.AreEqual(((DateTimeEditorValue)expectedResult).TimeZone, apiModel.TimeZone);
+ }
+
+ private DateTimePropertyEditorBase.DateTimeDataValueEditor CreateValueEditor(
+ DateTimeConfiguration.TimeZoneMode timeZoneMode = DateTimeConfiguration.TimeZoneMode.All,
+ string[]? timeZones = null)
+ {
+ var localizedTextServiceMock = new Mock();
+ localizedTextServiceMock.Setup(x => x.Localize(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>()))
+ .Returns((string key, string alias, CultureInfo _, IDictionary _) => $"{key}_{alias}");
+ var valueEditor = new DateTimePropertyEditorBase.DateTimeDataValueEditor(
+ Mock.Of(),
+ _jsonSerializer,
+ Mock.Of(),
+ new DataEditorAttribute(Constants.PropertyEditors.Aliases.DateTimeWithTimeZone),
+ localizedTextServiceMock.Object,
+ Mock.Of>(),
+ dt => dt.Date.ToString("yyyy-MM-ddTHH:mm:sszzz"))
+ {
+ ConfigurationObject = new DateTimeConfiguration
+ {
+ TimeZones = new DateTimeConfiguration.TimeZonesConfiguration
+ {
+ Mode = timeZoneMode,
+ TimeZones = timeZones?.ToList() ?? [],
+ },
+ },
+ };
+ return valueEditor;
+ }
+}
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/TimeOnlyPropertyEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/TimeOnlyPropertyEditorTests.cs
new file mode 100644
index 0000000000..5cab5f51df
--- /dev/null
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/TimeOnlyPropertyEditorTests.cs
@@ -0,0 +1,164 @@
+using System.Globalization;
+using System.Text.Json.Nodes;
+using Microsoft.Extensions.Logging;
+using Moq;
+using NUnit.Framework;
+using Umbraco.Cms.Core;
+using Umbraco.Cms.Core.IO;
+using Umbraco.Cms.Core.Models;
+using Umbraco.Cms.Core.Models.Editors;
+using Umbraco.Cms.Core.Models.Validation;
+using Umbraco.Cms.Core.PropertyEditors;
+using Umbraco.Cms.Core.PropertyEditors.ValueConverters;
+using Umbraco.Cms.Core.Serialization;
+using Umbraco.Cms.Core.Services;
+using Umbraco.Cms.Core.Strings;
+using Umbraco.Cms.Infrastructure.Models;
+using Umbraco.Cms.Infrastructure.Serialization;
+
+namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors;
+
+[TestFixture]
+public class TimeOnlyPropertyEditorTests
+{
+ private static readonly IJsonSerializer _jsonSerializer =
+ new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory());
+
+ private static readonly object[] _validateDateReceivedTestCases =
+ [
+ new object[] { null, true },
+ new object[] { JsonNode.Parse("{}"), false },
+ new object[] { JsonNode.Parse("{\"test\": \"\"}"), false },
+ new object[] { JsonNode.Parse("{\"date\": \"\"}"), false },
+ new object[] { JsonNode.Parse("{\"date\": \"INVALID\"}"), false },
+ new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T14:30:00\"}"), true }
+ ];
+
+ [TestCaseSource(nameof(_validateDateReceivedTestCases))]
+ public void Validates_Date_Received(object? value, bool expectedSuccess)
+ {
+ var editor = CreateValueEditor();
+ var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()).ToList();
+ if (expectedSuccess)
+ {
+ Assert.IsEmpty(result);
+ }
+ else
+ {
+ Assert.AreEqual(1, result.Count);
+
+ var validationResult = result.First();
+ Assert.AreEqual("validation_invalidDate", validationResult.ErrorMessage);
+ }
+ }
+
+ private static readonly object[] _timeOnlyParseValuesFromEditorTestCases =
+ [
+ new object[] { null, null, null },
+ new object[] { JsonNode.Parse("{\"date\": \"16:34\"}"), new DateTimeOffset(1, 1, 1, 16, 34, 0, TimeSpan.Zero), null },
+ ];
+
+ [TestCaseSource(nameof(_timeOnlyParseValuesFromEditorTestCases))]
+ public void Can_Parse_Values_From_Editor(
+ object? value,
+ DateTimeOffset? expectedDateTimeOffset,
+ string? expectedTimeZone)
+ {
+ var expectedJson = expectedDateTimeOffset is null ? null : _jsonSerializer.Serialize(
+ new DateTimeValueConverterBase.DateTimeDto
+ {
+ Date = expectedDateTimeOffset.Value,
+ TimeZone = expectedTimeZone,
+ });
+ var result = CreateValueEditor().FromEditor(
+ new ContentPropertyData(
+ value,
+ new DateTimeConfiguration
+ {
+ TimeZones = null,
+ }),
+ null);
+ Assert.AreEqual(expectedJson, result);
+ }
+
+ private static readonly object[][] _timeOnlyParseValuesToEditorTestCases =
+ [
+ [null, null, null],
+ [0, null, new DateTimeEditorValue { Date = "16:30:00", TimeZone = null }],
+ ];
+
+ [TestCaseSource(nameof(_timeOnlyParseValuesToEditorTestCases))]
+ public void Can_Parse_Values_To_Editor(
+ int? offset,
+ string? timeZone,
+ object? expectedResult)
+ {
+ var storedValue = offset is null
+ ? null
+ : new DateTimeValueConverterBase.DateTimeDto
+ {
+ Date = new DateTimeOffset(2025, 8, 20, 16, 30, 00, TimeSpan.FromHours(offset.Value)),
+ TimeZone = timeZone,
+ };
+ var valueEditor = CreateValueEditor(timeZoneMode: null);
+ var storedValueJson = storedValue is null ? null : _jsonSerializer.Serialize(storedValue);
+ var result = valueEditor.ToEditor(
+ new Property(new PropertyType(Mock.Of(), "dataType", ValueStorageType.Ntext))
+ {
+ Values =
+ [
+ new Property.PropertyValue
+ {
+ EditedValue = storedValueJson,
+ PublishedValue = storedValueJson,
+ }
+ ],
+ });
+
+ if (expectedResult is null)
+ {
+ Assert.IsNull(result);
+ return;
+ }
+
+ Assert.IsNotNull(result);
+ Assert.IsInstanceOf(result);
+ var apiModel = (DateTimeEditorValue)result;
+ Assert.AreEqual(((DateTimeEditorValue)expectedResult).Date, apiModel.Date);
+ Assert.AreEqual(((DateTimeEditorValue)expectedResult).TimeZone, apiModel.TimeZone);
+ }
+
+ private DateTimePropertyEditorBase.DateTimeDataValueEditor CreateValueEditor(
+ DateTimeConfiguration.TimeZoneMode? timeZoneMode = null,
+ string[]? timeZones = null)
+ {
+ var localizedTextServiceMock = new Mock();
+ localizedTextServiceMock.Setup(x => x.Localize(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>()))
+ .Returns((string key, string alias, CultureInfo _, IDictionary _) => $"{key}_{alias}");
+ var valueEditor = new DateTimePropertyEditorBase.DateTimeDataValueEditor(
+ Mock.Of(),
+ _jsonSerializer,
+ Mock.Of(),
+ new DataEditorAttribute(Constants.PropertyEditors.Aliases.TimeOnly),
+ localizedTextServiceMock.Object,
+ Mock.Of>(),
+ dt => dt.Date.ToString("HH:mm:ss"))
+ {
+ ConfigurationObject = new DateTimeConfiguration
+ {
+ TimeZones = timeZoneMode is null
+ ? null
+ : new DateTimeConfiguration.TimeZonesConfiguration
+ {
+ Mode = timeZoneMode.Value,
+ TimeZones = timeZones?.ToList() ?? [],
+ },
+ },
+ };
+ return valueEditor;
+ }
+}
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/DateOnlyValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/DateOnlyValueConverterTests.cs
new file mode 100644
index 0000000000..4ef1f44a1d
--- /dev/null
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/DateOnlyValueConverterTests.cs
@@ -0,0 +1,122 @@
+using Microsoft.Extensions.Logging;
+using Moq;
+using NUnit.Framework;
+using Umbraco.Cms.Core;
+using Umbraco.Cms.Core.Models.PublishedContent;
+using Umbraco.Cms.Core.PropertyEditors;
+using Umbraco.Cms.Core.PropertyEditors.ValueConverters;
+using Umbraco.Cms.Core.Serialization;
+using Umbraco.Cms.Infrastructure.Serialization;
+
+namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors.ValueConverters;
+
+[TestFixture]
+public class DateOnlyValueConverterTests
+{
+ private readonly IJsonSerializer _jsonSerializer =
+ new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory());
+
+ private static readonly DateTimeValueConverterBase.DateTimeDto _convertToObjectInputDate = new()
+ {
+ Date = new DateTimeOffset(2025, 08, 20, 16, 30, 0, TimeSpan.FromHours(-1)),
+ TimeZone = "Europe/Copenhagen",
+ };
+
+ [TestCase(Constants.PropertyEditors.Aliases.DateOnly, true)]
+ [TestCase(Constants.PropertyEditors.Aliases.DateTimeUnspecified, false)]
+ [TestCase(Constants.PropertyEditors.Aliases.DateTimeWithTimeZone, false)]
+ [TestCase(Constants.PropertyEditors.Aliases.TimeOnly, false)]
+ [TestCase(Constants.PropertyEditors.Aliases.DateTime, false)]
+ public void IsConverter_For(string propertyEditorAlias, bool expected)
+ {
+ var propertyType = Mock.Of(x => x.EditorAlias == propertyEditorAlias);
+ var converter = new DateOnlyValueConverter(Mock.Of(MockBehavior.Strict), Mock.Of>());
+
+ var result = converter.IsConverter(propertyType);
+
+ Assert.AreEqual(expected, result);
+ }
+
+ [Test]
+ public void GetPropertyValueType_ReturnsExpectedType()
+ {
+ var converter = new DateOnlyValueConverter(Mock.Of(MockBehavior.Strict), Mock.Of>());
+ var dataType = new PublishedDataType(
+ 0,
+ "test",
+ "test",
+ new Lazy