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 index b3da68c4cd63..e9e13964b301 100644 --- 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 @@ -54,6 +54,12 @@ export abstract class UmbPropertyEditorUiDateTimePickerElementBase @state() private _dateInputStep: number = 1; + @state() + private _min?: string; + + @state() + private _max?: string; + @state() private _selectedDate?: DateTime; @@ -117,6 +123,8 @@ export abstract class UmbPropertyEditorUiDateTimePickerElementBase if (!config) return; const timeFormat = config.getValueByAlias('timeFormat'); + this._min = this.#formatMinMaxValue(config.getValueByAlias('min'), 'min'); + this._max = this.#formatMinMaxValue(config.getValueByAlias('max'), 'max'); let timeZonePickerConfig: UmbTimeZonePickerValue | undefined = undefined; if (this._displayTimeZone) { @@ -256,6 +264,34 @@ export abstract class UmbPropertyEditorUiDateTimePickerElementBase } } + // A native date/datetime-local/time input ignores min/max unless they match the input's own + // value format, so normalise the configured bound to the active input type (e.g. a date-only + // bound like "2026-07-01" must gain a time component to constrain a datetime-local input). + #formatMinMaxValue(value: string | undefined, bound: 'min' | 'max'): string | undefined { + if (!value) return undefined; + + let dateTime = DateTime.fromISO(value); + if (!dateTime.isValid) { + dateTime = DateTime.fromSQL(value); + } + if (!dateTime.isValid) return value; + + switch (this._dateInputType) { + case 'date': + return dateTime.toFormat('yyyy-MM-dd'); + case 'time': + return dateTime.toFormat('HH:mm:ss'); + default: { + // A date-only bound should cover the whole day on a datetime-local field, mirroring + // how a date field treats it: the lower bound starts the day, the upper bound ends it. + if (/^\d{4}-\d{2}-\d{2}$/.test(value.trim())) { + dateTime = bound === 'max' ? dateTime.endOf('day') : dateTime.startOf('day'); + } + return dateTime.toFormat("yyyy-MM-dd'T'HH:mm:ss"); + } + } + } + #setTimeInputStep(timeFormat: string | undefined) { switch (timeFormat) { case 'HH:mm': @@ -396,6 +432,8 @@ export abstract class UmbPropertyEditorUiDateTimePickerElementBase { expect(element).to.be.instanceOf(UmbPropertyEditorUIDateTimePickerElement); }); + it('expands a date-only bound to span the whole day on a datetime-local field', async () => { + element.config = new UmbPropertyEditorConfigCollection([ + { alias: 'min', value: '2026-07-01' }, + { alias: 'max', value: '2026-07-30' }, + ]); + await element.updateComplete; + const inputElement = element.shadowRoot!.querySelector('umb-input-date') as UmbInputDateElement; + expect(inputElement.min).to.equal('2026-07-01T00:00:00'); + expect(inputElement.max).to.equal('2026-07-30T23:59:59'); + }); + + it('preserves an explicit time on a datetime-local bound', async () => { + element.config = new UmbPropertyEditorConfigCollection([ + { alias: 'min', value: '2026-07-01T09:30' }, + { alias: 'max', value: '2026-07-30T17:45' }, + ]); + await element.updateComplete; + const inputElement = element.shadowRoot!.querySelector('umb-input-date') as UmbInputDateElement; + expect(inputElement.min).to.equal('2026-07-01T09:30:00'); + expect(inputElement.max).to.equal('2026-07-30T17:45:00'); + }); + + it('parses a stored SQL-style datetime bound', async () => { + element.config = new UmbPropertyEditorConfigCollection([ + { alias: 'min', value: '2026-07-01 08:15:00' }, + { alias: 'max', value: '2026-07-30 17:45:00' }, + ]); + await element.updateComplete; + const inputElement = element.shadowRoot!.querySelector('umb-input-date') as UmbInputDateElement; + expect(inputElement.min).to.equal('2026-07-01T08:15:00'); + expect(inputElement.max).to.equal('2026-07-30T17:45:00'); + }); + + it('passes an unparseable bound through unchanged', async () => { + element.config = new UmbPropertyEditorConfigCollection([{ alias: 'min', value: 'not-a-date' }]); + await element.updateComplete; + const inputElement = element.shadowRoot!.querySelector('umb-input-date') as UmbInputDateElement; + expect(inputElement.min).to.equal('not-a-date'); + }); + + it('leaves a bound unset when its config value is absent', async () => { + element.config = new UmbPropertyEditorConfigCollection([{ alias: 'min', value: '2026-07-01' }]); + await element.updateComplete; + const inputElement = element.shadowRoot!.querySelector('umb-input-date') as UmbInputDateElement; + expect(inputElement.min).to.equal('2026-07-01T00:00:00'); + expect(inputElement.max).to.be.undefined; + }); + if ((window as UmbTestRunnerWindow).__UMBRACO_TEST_RUN_A11Y_TEST) { it('passes the a11y audit', async () => { await expect(element).shadowDom.to.be.accessible(defaultA11yConfig); @@ -34,6 +84,17 @@ describe('UmbPropertyEditorUIDateTimeWithTimeZonePickerElement', () => { expect(element).to.be.instanceOf(UmbPropertyEditorUIDateTimeWithTimeZonePickerElement); }); + it('applies min/max config to the date input, normalised for a datetime-local field', async () => { + element.config = new UmbPropertyEditorConfigCollection([ + { alias: 'min', value: '2026-07-01' }, + { alias: 'max', value: '2026-07-30' }, + ]); + await element.updateComplete; + const inputElement = element.shadowRoot!.querySelector('umb-input-date') as UmbInputDateElement; + expect(inputElement.min).to.equal('2026-07-01T00:00:00'); + expect(inputElement.max).to.equal('2026-07-30T23:59:59'); + }); + if ((window as UmbTestRunnerWindow).__UMBRACO_TEST_RUN_A11Y_TEST) { it('passes the a11y audit', async () => { await expect(element).shadowDom.to.be.accessible(defaultA11yConfig); @@ -53,6 +114,17 @@ describe('UmbPropertyEditorUIDateOnlyPickerElement', () => { expect(element).to.be.instanceOf(UmbPropertyEditorUIDateOnlyPickerElement); }); + it('applies min/max config to the date input as date-only values', async () => { + element.config = new UmbPropertyEditorConfigCollection([ + { alias: 'min', value: '2026-07-01' }, + { alias: 'max', value: '2026-07-30' }, + ]); + await element.updateComplete; + const inputElement = element.shadowRoot!.querySelector('umb-input-date') as UmbInputDateElement; + expect(inputElement.min).to.equal('2026-07-01'); + expect(inputElement.max).to.equal('2026-07-30'); + }); + if ((window as UmbTestRunnerWindow).__UMBRACO_TEST_RUN_A11Y_TEST) { it('passes the a11y audit', async () => { await expect(element).shadowDom.to.be.accessible(defaultA11yConfig); @@ -71,6 +143,17 @@ describe('UmbPropertyEditorUITimeOnlyPickerElement', () => { expect(element).to.be.instanceOf(UmbPropertyEditorUITimeOnlyPickerElement); }); + it('applies min/max config to the date input as time values', async () => { + element.config = new UmbPropertyEditorConfigCollection([ + { alias: 'min', value: '09:00:00' }, + { alias: 'max', value: '17:30:00' }, + ]); + await element.updateComplete; + const inputElement = element.shadowRoot!.querySelector('umb-input-date') as UmbInputDateElement; + expect(inputElement.min).to.equal('09:00:00'); + expect(inputElement.max).to.equal('17:30:00'); + }); + if ((window as UmbTestRunnerWindow).__UMBRACO_TEST_RUN_A11Y_TEST) { it('passes the a11y audit', async () => { await expect(element).shadowDom.to.be.accessible(defaultA11yConfig);