From 10b65fdbd71f989b5ff7f84aab93c4137768399b Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Fri, 16 May 2025 12:57:16 +0300 Subject: [PATCH 1/2] refactor(date-picker): Interaction behavior in readonly mode --- src/components/calendar/helpers.spec.ts | 3 + .../date-picker/date-picker-form.spec.ts | 366 ++++++++++++++++ .../date-picker/date-picker.spec.ts | 412 +++--------------- src/components/date-picker/date-picker.ts | 349 ++++++++------- 4 files changed, 631 insertions(+), 499 deletions(-) create mode 100644 src/components/date-picker/date-picker-form.spec.ts diff --git a/src/components/calendar/helpers.spec.ts b/src/components/calendar/helpers.spec.ts index babcb8403..52270de71 100644 --- a/src/components/calendar/helpers.spec.ts +++ b/src/components/calendar/helpers.spec.ts @@ -51,6 +51,9 @@ export function getDayViewDOM(element: IgcDaysViewComponent) { ) ); }, + get current() { + return root.querySelector('span[part~="current"]')!; + }, }, }; } diff --git a/src/components/date-picker/date-picker-form.spec.ts b/src/components/date-picker/date-picker-form.spec.ts new file mode 100644 index 000000000..61c7f3d5d --- /dev/null +++ b/src/components/date-picker/date-picker-form.spec.ts @@ -0,0 +1,366 @@ +import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; +import { CalendarDay, toCalendarDay } from '../calendar/model.js'; +import { type DateRangeDescriptor, DateRangeType } from '../calendar/types.js'; +import { defineComponents } from '../common/definitions/defineComponents.js'; +import { equal } from '../common/util.js'; +import { + type ValidationContainerTestsParams, + createFormAssociatedTestBed, + runValidationContainerTests, + simulatePointerDown, +} from '../common/utils.spec.js'; +import IgcDateTimeInputComponent from '../date-time-input/date-time-input.js'; +import IgcDatePickerComponent from './date-picker.js'; + +describe('igc-datepicker form integration', () => { + before(() => defineComponents(IgcDatePickerComponent)); + + function checkDatesEqual(a: CalendarDay | Date, b: CalendarDay | Date) { + expect(equal(toCalendarDay(a), toCalendarDay(b))).to.be.true; + } + + describe('Initial validation', () => { + it('should not enter in invalid state when clicking the calendar toggle part', async () => { + const picker = await fixture( + html`` + ); + const dateTimeInput = picker.renderRoot.querySelector( + IgcDateTimeInputComponent.tagName + )!; + const icon = picker.renderRoot.querySelector('[name="today"]')!; + + expect(picker.invalid).to.be.false; + expect(dateTimeInput.invalid).to.be.false; + + simulatePointerDown(icon); + await elementUpdated(picker); + + expect(picker.invalid).to.be.false; + expect(dateTimeInput.invalid).to.be.false; + }); + }); + + describe('Form integration', () => { + const today = CalendarDay.today; + const spec = createFormAssociatedTestBed( + html`` + ); + + beforeEach(async () => { + await spec.setup(IgcDatePickerComponent.tagName); + }); + + it('should be form associated', () => { + expect(spec.element.form).to.equal(spec.form); + }); + + it('should not participate in form submission if the value is empty/invalid', () => { + spec.assertSubmitHasValue(null); + }); + + it('should participate in form submission if there is a value and the value adheres to the validation constraints', () => { + spec.setProperties({ value: today.native }); + spec.assertSubmitHasValue(spec.element.value?.toISOString()); + }); + + it('should reset to its default value state on form reset', () => { + spec.setProperties({ value: today.native }); + spec.reset(); + + expect(spec.element.value).to.be.null; + }); + + it('should reset to the new default value after setAttribute() call', () => { + spec.setAttributes({ value: today.native.toISOString() }); + spec.setProperties({ value: today.add('day', 180).native }); + spec.reset(); + + checkDatesEqual(spec.element.value!, today); + spec.assertSubmitHasValue(today.native.toISOString()); + }); + + it('should reflect disabled ancestor state (fieldset/form)', () => { + spec.setAncestorDisabledState(true); + expect(spec.element.disabled).to.be.true; + + spec.setAncestorDisabledState(false); + expect(spec.element.disabled).to.be.false; + }); + + it('should enforce required constraint', () => { + spec.setProperties({ required: true }); + spec.assertSubmitFails(); + + spec.setProperties({ value: today.native }); + spec.assertSubmitPasses(); + }); + + it('should enforce min value constraint', () => { + // No value - submit passes + spec.setProperties({ min: new Date(2026, 0, 1) }); + spec.assertSubmitPasses(); + + // Invalid min constraint + spec.setProperties({ value: new Date(2022, 0, 1) }); + spec.assertSubmitFails(); + + // Valid value + spec.setProperties({ value: new Date(2026, 0, 2) }); + spec.assertSubmitPasses(); + }); + + it('should enforce max value constraint', () => { + // No value - submit passes + spec.setProperties({ max: new Date(2020, 0, 1) }); + spec.assertSubmitPasses(); + + // Invalid max constraint + spec.setProperties({ value: today.native }); + spec.assertSubmitFails(); + + // Valid value + spec.setProperties({ value: new Date(2020, 0, 1) }); + spec.assertSubmitPasses(); + }); + + it('should enforce min value constraint with string property', () => { + // No value - submit passes + spec.setProperties({ min: new Date(2026, 0, 1).toISOString() }); + spec.assertSubmitPasses(); + + // Invalid min constraint + spec.setProperties({ value: new Date(2022, 0, 1).toISOString() }); + spec.assertSubmitFails(); + + // Valid value + spec.setProperties({ value: new Date(2026, 0, 2).toISOString() }); + spec.assertSubmitPasses(); + }); + + it('should enforce max value constraint with string property', () => { + // No value - submit passes + spec.setProperties({ max: new Date(2020, 0, 1).toISOString() }); + spec.assertSubmitPasses(); + + // Invalid max constraint + spec.setProperties({ value: today.native }); + spec.assertSubmitFails(); + + // Valid value + spec.setProperties({ value: new Date(2020, 0, 1).toISOString() }); + spec.assertSubmitPasses(); + }); + + it('should invalidate the component if a disabled date is typed in the input', () => { + const minDate = new Date(2024, 1, 1); + const maxDate = new Date(2024, 1, 28); + + const disabledDates: DateRangeDescriptor[] = [ + { + type: DateRangeType.Between, + dateRange: [minDate, maxDate], + }, + ]; + + spec.setProperties({ disabledDates, value: new Date(2024, 1, 26) }); + + expect(spec.element.invalid).to.be.true; + spec.assertSubmitFails(); + }); + + it('should enforce custom constraint', () => { + spec.element.setCustomValidity('invalid'); + spec.assertSubmitFails(); + + spec.element.setCustomValidity(''); + spec.assertSubmitPasses(); + }); + + it('synchronous form validation', () => { + spec.setProperties({ required: true }, false); + + expect(spec.form.checkValidity()).to.be.false; + spec.assertSubmitFails(); + + spec.reset(); + + spec.setProperties({ value: today.native }, false); + + expect(spec.form.checkValidity()).to.be.true; + spec.assertSubmitPasses(); + }); + }); + + describe('defaultValue', () => { + const today = CalendarDay.today; + + describe('Form integration', () => { + const spec = createFormAssociatedTestBed(html` + + `); + + beforeEach(async () => { + await spec.setup(IgcDatePickerComponent.tagName); + }); + + it('correct initial state', () => { + spec.assertIsPristine(); + checkDatesEqual(spec.element.value!, today); + }); + + it('is correctly submitted', () => { + spec.assertSubmitHasValue(today.native.toISOString()); + }); + + it('is correctly reset', () => { + spec.setProperties({ value: today.add('day', 1).native }); + spec.reset(); + + checkDatesEqual(spec.element.value!, today); + }); + }); + + describe('Validation', () => { + const spec = createFormAssociatedTestBed(html` + + `); + + beforeEach(async () => { + await spec.setup(IgcDatePickerComponent.tagName); + }); + + it('fails required validation', () => { + spec.setProperties({ required: true }); + spec.assertIsPristine(); + spec.assertSubmitFails(); + }); + + it('passes required validation when updating defaultValue', () => { + spec.setProperties({ required: true, defaultValue: today.native }); + spec.assertIsPristine(); + + spec.assertSubmitPasses(); + }); + + it('fails min validation', () => { + spec.setProperties({ + min: today.native, + defaultValue: today.add('day', -1).native, + }); + + spec.assertIsPristine(); + spec.assertSubmitFails(); + }); + + it('passes min validation', () => { + spec.setProperties({ min: today.native, defaultValue: today.native }); + + spec.assertIsPristine(); + spec.assertSubmitPasses(); + }); + + it('fails max validation', () => { + spec.setProperties({ + max: today.native, + defaultValue: today.add('day', 1).native, + }); + + spec.assertIsPristine(); + spec.assertSubmitFails(); + }); + + it('passes max validation', () => { + spec.setProperties({ + max: today.native, + defaultValue: today.native, + }); + + spec.assertIsPristine(); + spec.assertSubmitPasses(); + }); + + it('fails for range constraints', () => { + const minDate = new Date(2024, 1, 1); + const maxDate = new Date(2024, 1, 28); + + const disabledDates: DateRangeDescriptor[] = [ + { + type: DateRangeType.Between, + dateRange: [minDate, maxDate], + }, + ]; + + spec.setProperties({ + disabledDates, + defaultValue: new Date(2024, 1, 28), + }); + + spec.assertIsPristine(); + spec.assertSubmitFails(); + }); + + it('passes for range constraints', () => { + const minDate = new Date(2024, 1, 1); + const maxDate = new Date(2024, 1, 28); + + const disabledDates: DateRangeDescriptor[] = [ + { + type: DateRangeType.Between, + dateRange: [minDate, maxDate], + }, + ]; + + spec.setProperties({ + disabledDates, + defaultValue: new Date(2024, 1, 29), + }); + + spec.assertIsPristine(); + spec.assertSubmitPasses(); + }); + }); + }); + + describe('Validation message slots', () => { + it('', () => { + const now = CalendarDay.today; + const tomorrow = now.add('day', 1); + const yesterday = now.add('day', -1); + + const testParameters: ValidationContainerTestsParams[] = + [ + { slots: ['valueMissing'], props: { required: true } }, // value-missing slot + { + slots: ['rangeOverflow'], + props: { value: now.native, max: yesterday.native }, // range-overflow slot + }, + { + slots: ['rangeUnderflow'], + props: { value: now.native, min: tomorrow.native }, // range-underflow slot + }, + { + slots: ['badInput'], + props: { + value: now.native, + disabledDates: [ + { + type: DateRangeType.Between, + dateRange: [yesterday.native, tomorrow.native], // bad-input slot + }, + ], + }, + }, + { slots: ['customError'] }, // custom-error slot + { slots: ['invalid'], props: { required: true } }, // invalid slot + ]; + + runValidationContainerTests(IgcDatePickerComponent, testParameters); + }); + }); +}); diff --git a/src/components/date-picker/date-picker.spec.ts b/src/components/date-picker/date-picker.spec.ts index cdf71a425..ad17f3acc 100644 --- a/src/components/date-picker/date-picker.spec.ts +++ b/src/components/date-picker/date-picker.spec.ts @@ -1,10 +1,9 @@ import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; import { spy } from 'sinon'; - import IgcCalendarComponent from '../calendar/calendar.js'; -import IgcDaysViewComponent from '../calendar/days-view/days-view.js'; +import { getCalendarDOM, getDayViewDOM } from '../calendar/helpers.spec.js'; import { CalendarDay, toCalendarDay } from '../calendar/model.js'; -import { type DateRangeDescriptor, DateRangeType } from '../calendar/types.js'; +import { DateRangeType } from '../calendar/types.js'; import { altKey, arrowDown, @@ -12,14 +11,8 @@ import { escapeKey, } from '../common/controllers/key-bindings.js'; import { defineComponents } from '../common/definitions/defineComponents.js'; -import { - type ValidationContainerTestsParams, - createFormAssociatedTestBed, - runValidationContainerTests, - simulateClick, - simulateKeyboard, - simulatePointerDown, -} from '../common/utils.spec.js'; +import { equal } from '../common/util.js'; +import { simulateClick, simulateKeyboard } from '../common/utils.spec.js'; import IgcDateTimeInputComponent from '../date-time-input/date-time-input.js'; import IgcDatePickerComponent from './date-picker.js'; @@ -37,6 +30,17 @@ describe('Date picker', () => { return picker.renderRoot.querySelector('label')!; } + function selectCurrentDate(calendar: IgcCalendarComponent) { + const { current } = getDayViewDOM( + getCalendarDOM(calendar).views.days + ).dates; + simulateClick(current.children[0]); + } + + function checkDatesEqual(a: CalendarDay | Date, b: CalendarDay | Date) { + expect(equal(toCalendarDay(a), toCalendarDay(b))).to.be.true; + } + let picker: IgcDatePickerComponent; let dateTimeInput: IgcDateTimeInputComponent; let calendar: IgcCalendarComponent; @@ -892,367 +896,81 @@ describe('Date picker', () => { expect(picker.open).to.be.true; }); - }); - - describe('Form integration', () => { - const today = CalendarDay.today; - const spec = createFormAssociatedTestBed( - html`` - ); - - beforeEach(async () => { - await spec.setup(IgcDatePickerComponent.tagName); - }); - - it('should be form associated', () => { - expect(spec.element.form).to.equal(spec.form); - }); - - it('should not participate in form submission if the value is empty/invalid', () => { - spec.assertSubmitHasValue(null); - }); - - it('should participate in form submission if there is a value and the value adheres to the validation constraints', () => { - spec.setProperties({ value: today.native }); - spec.assertSubmitHasValue(spec.element.value?.toISOString()); - }); - - it('should reset to its default value state on form reset', () => { - spec.setProperties({ value: today.native }); - spec.reset(); - - expect(spec.element.value).to.be.null; - }); - - it('should reset to the new default value after setAttribute() call', () => { - spec.setAttributes({ value: today.native.toISOString() }); - spec.setProperties({ value: today.add('day', 180).native }); - spec.reset(); - - checkDatesEqual(spec.element.value!, today); - spec.assertSubmitHasValue(today.native.toISOString()); - }); - - it('should reflect disabled ancestor state (fieldset/form)', () => { - spec.setAncestorDisabledState(true); - expect(spec.element.disabled).to.be.true; - - spec.setAncestorDisabledState(false); - expect(spec.element.disabled).to.be.false; - }); - - it('should enforce required constraint', () => { - spec.setProperties({ required: true }); - spec.assertSubmitFails(); - - spec.setProperties({ value: today.native }); - spec.assertSubmitPasses(); - }); - - it('should enforce min value constraint', () => { - // No value - submit passes - spec.setProperties({ min: new Date(2026, 0, 1) }); - spec.assertSubmitPasses(); - - // Invalid min constraint - spec.setProperties({ value: new Date(2022, 0, 1) }); - spec.assertSubmitFails(); - - // Valid value - spec.setProperties({ value: new Date(2026, 0, 2) }); - spec.assertSubmitPasses(); - }); - - it('should enforce max value constraint', () => { - // No value - submit passes - spec.setProperties({ max: new Date(2020, 0, 1) }); - spec.assertSubmitPasses(); - - // Invalid max constraint - spec.setProperties({ value: today.native }); - spec.assertSubmitFails(); - - // Valid value - spec.setProperties({ value: new Date(2020, 0, 1) }); - spec.assertSubmitPasses(); - }); - - it('should enforce min value constraint with string property', () => { - // No value - submit passes - spec.setProperties({ min: new Date(2026, 0, 1).toISOString() }); - spec.assertSubmitPasses(); - - // Invalid min constraint - spec.setProperties({ value: new Date(2022, 0, 1).toISOString() }); - spec.assertSubmitFails(); - - // Valid value - spec.setProperties({ value: new Date(2026, 0, 2).toISOString() }); - spec.assertSubmitPasses(); - }); - - it('should enforce max value constraint with string property', () => { - // No value - submit passes - spec.setProperties({ max: new Date(2020, 0, 1).toISOString() }); - spec.assertSubmitPasses(); - - // Invalid max constraint - spec.setProperties({ value: today.native }); - spec.assertSubmitFails(); - - // Valid value - spec.setProperties({ value: new Date(2020, 0, 1).toISOString() }); - spec.assertSubmitPasses(); - }); - - it('should invalidate the component if a disabled date is typed in the input', () => { - const minDate = new Date(2024, 1, 1); - const maxDate = new Date(2024, 1, 28); - - const disabledDates: DateRangeDescriptor[] = [ - { - type: DateRangeType.Between, - dateRange: [minDate, maxDate], - }, - ]; - - spec.setProperties({ disabledDates, value: new Date(2024, 1, 26) }); - - expect(spec.element.invalid).to.be.true; - spec.assertSubmitFails(); - }); - - it('should enforce custom constraint', () => { - spec.element.setCustomValidity('invalid'); - spec.assertSubmitFails(); - - spec.element.setCustomValidity(''); - spec.assertSubmitPasses(); - }); - - it('synchronous form validation', () => { - spec.setProperties({ required: true }, false); - - expect(spec.form.checkValidity()).to.be.false; - spec.assertSubmitFails(); - - spec.reset(); - - spec.setProperties({ value: today.native }, false); - - expect(spec.form.checkValidity()).to.be.true; - spec.assertSubmitPasses(); - }); - }); - - describe('defaultValue', () => { - const today = CalendarDay.today; - - describe('Form integration', () => { - const spec = createFormAssociatedTestBed(html` - - `); - - beforeEach(async () => { - await spec.setup(IgcDatePickerComponent.tagName); - }); - - it('correct initial state', () => { - spec.assertIsPristine(); - checkDatesEqual(spec.element.value!, today); - }); - - it('is correctly submitted', () => { - spec.assertSubmitHasValue(today.native.toISOString()); - }); - - it('is correctly reset', () => { - spec.setProperties({ value: today.add('day', 1).native }); - spec.reset(); - - checkDatesEqual(spec.element.value!, today); - }); - }); - - describe('Validation', () => { - const spec = createFormAssociatedTestBed(html` - - `); - beforeEach(async () => { - await spec.setup(IgcDatePickerComponent.tagName); - }); + describe('Readonly state', () => { + describe('Dropdown mode', () => { + beforeEach(async () => { + picker.readOnly = true; + picker.value = CalendarDay.today.native; + await elementUpdated(picker); + }); - it('fails required validation', () => { - spec.setProperties({ required: true }); - spec.assertIsPristine(); - spec.assertSubmitFails(); - }); + it('should not show the picker on calendar icon click', async () => { + simulateClick(getIcon(pickerShowIcon)); + await elementUpdated(picker); - it('passes required validation when updating defaultValue', () => { - spec.setProperties({ required: true, defaultValue: today.native }); - spec.assertIsPristine(); + expect(picker.open).to.be.false; + }); - spec.assertSubmitPasses(); - }); + it('should not show the picker on keyboard shortcut', async () => { + simulateKeyboard(picker, [altKey, arrowDown]); + await elementUpdated(picker); - it('fails min validation', () => { - spec.setProperties({ - min: today.native, - defaultValue: today.add('day', -1).native, + expect(picker.open).to.be.false; }); - spec.assertIsPristine(); - spec.assertSubmitFails(); - }); - - it('passes min validation', () => { - spec.setProperties({ min: today.native, defaultValue: today.native }); + it('should not clear the value by clicking on the clear icon', async () => { + simulateClick(getIcon(pickerClearIcon)); + await elementUpdated(picker); - spec.assertIsPristine(); - spec.assertSubmitPasses(); + checkDatesEqual(picker.value!, CalendarDay.today); + }); }); - it('fails max validation', () => { - spec.setProperties({ - max: today.native, - defaultValue: today.add('day', 1).native, + describe('Dialog mode', () => { + beforeEach(async () => { + picker.readOnly = true; + picker.mode = 'dialog'; + picker.label = 'Label'; + picker.value = CalendarDay.today.native; + await elementUpdated(picker); }); - spec.assertIsPristine(); - spec.assertSubmitFails(); - }); + it('should not show the dialog on calendar icon click', async () => { + simulateClick(getIcon(pickerShowIcon)); + await elementUpdated(picker); - it('passes max validation', () => { - spec.setProperties({ - max: today.native, - defaultValue: today.native, + expect(picker.open).to.be.false; }); - spec.assertIsPristine(); - spec.assertSubmitPasses(); - }); + it('should not show the dialog on label click', async () => { + simulateClick(getLabel()); + await elementUpdated(picker); - it('fails for range constraints', () => { - const minDate = new Date(2024, 1, 1); - const maxDate = new Date(2024, 1, 28); + expect(picker.open).to.be.false; + }); - const disabledDates: DateRangeDescriptor[] = [ - { - type: DateRangeType.Between, - dateRange: [minDate, maxDate], - }, - ]; + it('should not show the dialog on input click', async () => { + simulateClick(dateTimeInput.renderRoot.querySelector('input')!); + await elementUpdated(picker); - spec.setProperties({ - disabledDates, - defaultValue: new Date(2024, 1, 28), + expect(picker.open).to.be.false; }); - spec.assertIsPristine(); - spec.assertSubmitFails(); - }); + it('should not show the dialog on keyboard shortcut', async () => { + simulateKeyboard(picker, [altKey, arrowDown]); + await elementUpdated(picker); - it('passes for range constraints', () => { - const minDate = new Date(2024, 1, 1); - const maxDate = new Date(2024, 1, 28); + expect(picker.open).to.be.false; + }); - const disabledDates: DateRangeDescriptor[] = [ - { - type: DateRangeType.Between, - dateRange: [minDate, maxDate], - }, - ]; + it('should not clear the value by clicking on the clear icon', async () => { + simulateClick(getIcon(pickerClearIcon)); + await elementUpdated(picker); - spec.setProperties({ - disabledDates, - defaultValue: new Date(2024, 1, 29), + checkDatesEqual(picker.value!, CalendarDay.today); }); - - spec.assertIsPristine(); - spec.assertSubmitPasses(); }); }); }); - - describe('Initial validation', () => { - it('should not enter in invalid state when clicking the calendar toggle part', async () => { - picker = await fixture( - html`` - ); - dateTimeInput = picker.renderRoot.querySelector( - IgcDateTimeInputComponent.tagName - )!; - const icon = picker.renderRoot.querySelector( - `[name='${pickerShowIcon}']` - )!; - - expect(picker.invalid).to.be.false; - expect(dateTimeInput.invalid).to.be.false; - - simulatePointerDown(icon); - await elementUpdated(picker); - - expect(picker.invalid).to.be.false; - expect(dateTimeInput.invalid).to.be.false; - }); - }); - - describe('Validation message slots', () => { - it('', () => { - const now = CalendarDay.today; - const tomorrow = now.add('day', 1); - const yesterday = now.add('day', -1); - - const testParameters: ValidationContainerTestsParams[] = - [ - { slots: ['valueMissing'], props: { required: true } }, // value-missing slot - { - slots: ['rangeOverflow'], - props: { value: now.native, max: yesterday.native }, // range-overflow slot - }, - { - slots: ['rangeUnderflow'], - props: { value: now.native, min: tomorrow.native }, // range-underflow slot - }, - { - slots: ['badInput'], - props: { - value: now.native, - disabledDates: [ - { - type: DateRangeType.Between, - dateRange: [yesterday.native, tomorrow.native], // bad-input slot - }, - ], - }, - }, - { slots: ['customError'] }, // custom-error slot - { slots: ['invalid'], props: { required: true } }, // invalid slot - ]; - - runValidationContainerTests(IgcDatePickerComponent, testParameters); - }); - }); }); - -const selectCurrentDate = (calendar: IgcCalendarComponent) => { - const daysView = calendar.renderRoot.querySelector( - IgcDaysViewComponent.tagName - )!; - - const currentDaySpan = daysView.renderRoot.querySelector( - 'span[part~="current"]' - )!; - simulateClick(currentDaySpan?.children[0]); -}; - -function checkDatesEqual(a: CalendarDay | Date, b: CalendarDay | Date) { - expect(toCalendarDay(a).equalTo(toCalendarDay(b))).to.be.true; -} diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index 06fdfa0c7..af18bfeec 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -35,7 +35,11 @@ import { createFormValueState, defaultDateTimeTransformers, } from '../common/mixins/forms/form-value.js'; -import { createCounter, findElementFromEventPath } from '../common/util.js'; +import { + createCounter, + findElementFromEventPath, + isEmpty, +} from '../common/util.js'; import IgcDateTimeInputComponent from '../date-time-input/date-time-input.js'; import type { DatePart } from '../date-time-input/date-util.js'; import IgcDialogComponent from '../dialog/dialog.js'; @@ -159,15 +163,8 @@ export default class IgcDatePickerComponent extends FormAssociatedRequiredMixin( delegatesFocus: true, }; - private static readonly increment = createCounter(); - protected inputId = `date-picker-${IgcDatePickerComponent.increment()}`; - - protected override get __validators() { - return datePickerValidators; - } - /* blazorSuppress */ - public static register() { + public static register(): void { registerComponent( IgcDatePickerComponent, IgcCalendarComponent, @@ -180,6 +177,15 @@ export default class IgcDatePickerComponent extends FormAssociatedRequiredMixin( ); } + //#region Private properties and state + + private static readonly _increment = createCounter(); + protected _inputId = `date-picker-${IgcDatePickerComponent._increment()}`; + + protected override get __validators() { + return datePickerValidators; + } + private _activeDate: Date | null = null; private _min: Date | null = null; private _max: Date | null = null; @@ -188,33 +194,37 @@ export default class IgcDatePickerComponent extends FormAssociatedRequiredMixin( private _displayFormat?: string; private _inputFormat?: string; - private get isDropDown() { - return this.mode === 'dropdown'; - } + protected override readonly _formValue: FormValue; @query(IgcDateTimeInputComponent.tagName) - private _input!: IgcDateTimeInputComponent; + private readonly _input!: IgcDateTimeInputComponent; @query(IgcCalendarComponent.tagName) - private _calendar!: IgcCalendarComponent; + private readonly _calendar!: IgcCalendarComponent; @queryAssignedElements({ slot: 'prefix' }) - private prefixes!: Array; + private readonly _prefixes!: HTMLElement[]; @queryAssignedElements({ slot: 'suffix' }) - private suffixes!: Array; + private readonly _suffixes!: HTMLElement[]; @queryAssignedElements({ slot: 'actions' }) - private actions!: Array; + private readonly _actions!: HTMLElement[]; @queryAssignedElements({ slot: 'header-date' }) - private headerDateSlotItems!: Array; + private readonly _headerSlotItems!: HTMLElement[]; + + private get _isDropDown(): boolean { + return this.mode === 'dropdown'; + } protected get _isMaterialTheme(): boolean { return getThemeController(this)?.theme === 'material'; } - protected override _formValue: FormValue; + //#endregion + + //#region Public properties and attributes /** * Sets the state of the datepicker dropdown. @@ -286,7 +296,7 @@ export default class IgcDatePickerComponent extends FormAssociatedRequiredMixin( @property({ converter: convertToDate }) public set min(value: Date | string | null | undefined) { this._min = convertToDate(value); - this.setDateConstraints(); + this._setDateConstraints(); this._updateValidity(); } @@ -301,7 +311,7 @@ export default class IgcDatePickerComponent extends FormAssociatedRequiredMixin( @property({ converter: convertToDate }) public set max(value: Date | string | null | undefined) { this._max = convertToDate(value); - this.setDateConstraints(); + this._setDateConstraints(); this._updateValidity(); } @@ -341,7 +351,7 @@ export default class IgcDatePickerComponent extends FormAssociatedRequiredMixin( @property({ attribute: false }) public set disabledDates(dates: DateRangeDescriptor[]) { this._disabledDates = dates; - this.setDateConstraints(); + this._setDateConstraints(); this._updateValidity(); } @@ -427,14 +437,22 @@ export default class IgcDatePickerComponent extends FormAssociatedRequiredMixin( public resourceStrings: IgcCalendarResourceStrings = IgcCalendarResourceStringEN; + /** Sets the start day of the week for the calendar. */ + @property({ attribute: 'week-start' }) + public weekStart: WeekDays = 'sunday'; + + //#endregion + + //#region Watchers + @watch('open') - protected openChange() { + protected _openChange(): void { this._rootClickController.update(); } - /** Sets the start day of the week for the calendar. */ - @property({ attribute: 'week-start' }) - public weekStart: WeekDays = 'sunday'; + //#endregion + + //#region Life-cycle hooks constructor() { super(); @@ -444,78 +462,73 @@ export default class IgcDatePickerComponent extends FormAssociatedRequiredMixin( transformers: defaultDateTimeTransformers, }); - this.addEventListener('focusin', this.handleFocusIn); - this.addEventListener('focusout', this.handleFocusOut); + this.addEventListener('focusin', this._handleFocusIn); + this.addEventListener('focusout', this._handleFocusOut); - this._rootClickController.update({ hideCallback: this.handleClosing }); + this._rootClickController.update({ hideCallback: this._handleClosing }); addKeybindings(this, { - skip: () => this.disabled, + skip: () => this.disabled || this.readOnly, bindingDefaults: { preventDefault: true }, }) .set([altKey, arrowDown], this.handleAnchorClick) - .set([altKey, arrowUp], this.onEscapeKey) - .set(escapeKey, this.onEscapeKey); + .set([altKey, arrowUp], this._onEscapeKey) + .set(escapeKey, this._onEscapeKey); } - protected override createRenderRoot() { + protected override createRenderRoot(): HTMLElement | DocumentFragment { const root = super.createRenderRoot(); root.addEventListener('slotchange', () => this.requestUpdate()); return root; } - /** Clears the input part of the component of any user input */ - public clear() { - this.value = null; - this._input?.clear(); - } + //#endregion - /** Increments the passed in date part */ - public stepUp(datePart?: DatePart, delta?: number): void { - this._input.stepUp(datePart, delta); - } + //#region Private methods - /** Decrements the passed in date part */ - public stepDown(datePart?: DatePart, delta?: number): void { - this._input.stepDown(datePart, delta); - } + private _setDateConstraints(): void { + const dates: DateRangeDescriptor[] = []; + if (this._min) { + dates.push({ + type: DateRangeType.Before, + dateRange: [this._min], + }); + } + if (this._max) { + dates.push({ + type: DateRangeType.After, + dateRange: [this._max], + }); + } + if (!isEmpty(this._disabledDates ?? [])) { + dates.push(...this.disabledDates); + } - /** Selects the text in the input of the component */ - public select(): void { - this._input.select(); + this._dateConstraints = isEmpty(dates) ? undefined : dates; } - /** Sets the text selection range in the input of the component */ - public setSelectionRange( - start: number, - end: number, - direction?: SelectionRangeDirection - ): void { - this._input.setSelectionRange(start, end, direction); + private async _shouldCloseCalendarDropdown(): Promise { + if (!this.keepOpenOnSelect && (await this._hide(true))) { + this._input.focus(); + this._input.select(); + } } - /* Replaces the selected text in the input and re-applies the mask */ - public setRangeText( - replacement: string, - start: number, - end: number, - mode?: RangeTextSelectMode - ): void { - this._input.setRangeText(replacement, start, end, mode); - this.value = this._input.value; - } + //#endregion + + //#region Event handlers - protected async onEscapeKey() { + protected async _onEscapeKey(): Promise { if (await this._hide(true)) { this._input.focus(); } } - protected handleFocusIn() { + protected _handleFocusIn(): void { this._dirty = true; } - protected handleFocusOut({ relatedTarget }: FocusEvent) { + protected _handleFocusOut({ relatedTarget }: FocusEvent): void { if (!this.contains(relatedTarget as Node)) { this.checkValidity(); } @@ -529,14 +542,14 @@ export default class IgcDatePickerComponent extends FormAssociatedRequiredMixin( event.preventDefault(); } - protected handleInputClick(event: Event) { + protected _handleInputClick(event: Event): void { if (findElementFromEventPath('input', event)) { // Open only if the click originates from the underlying input this.handleAnchorClick(); } } - protected override async handleAnchorClick() { + protected override async handleAnchorClick(): Promise { this._calendar.activeDate = this.value ?? this._calendar.activeDate; super.handleAnchorClick(); @@ -544,20 +557,15 @@ export default class IgcDatePickerComponent extends FormAssociatedRequiredMixin( this._calendar[focusActiveDate](); } - private async _shouldCloseCalendarDropdown() { - if (!this.keepOpenOnSelect && (await this._hide(true))) { - this._input.focus(); - this._input.select(); - } - } - - protected handleInputChangeEvent(event: CustomEvent) { + protected _handleInputChangeEvent(event: CustomEvent): void { event.stopPropagation(); this.value = (event.target as IgcDateTimeInputComponent).value!; this.emitEvent('igcChange', { detail: this.value }); } - protected async handleCalendarChangeEvent(event: CustomEvent) { + protected async _handleCalendarChangeEvent( + event: CustomEvent + ): Promise { event.stopPropagation(); if (this.readOnly) { @@ -573,7 +581,7 @@ export default class IgcDatePickerComponent extends FormAssociatedRequiredMixin( this._shouldCloseCalendarDropdown(); } - protected handleInputEvent(event: CustomEvent) { + protected _handleInputEvent(event: CustomEvent): void { event.stopPropagation(); if (this.nonEditable) { @@ -585,45 +593,77 @@ export default class IgcDatePickerComponent extends FormAssociatedRequiredMixin( this.emitEvent('igcInput', { detail: this.value }); } - protected handleClosing() { + protected _handleClosing(): void { this._hide(true); } - protected handleDialogClosing(event: Event) { + protected _handleDialogClosing(event: Event): void { event.stopPropagation(); this._hide(true); } - protected handleDialogClosed(event: Event) { + protected _handleDialogClosed(event: Event): void { event.stopPropagation(); } - private setDateConstraints() { - const dates: DateRangeDescriptor[] = []; - if (this._min) { - dates.push({ - type: DateRangeType.Before, - dateRange: [this._min], - }); - } - if (this._max) { - dates.push({ - type: DateRangeType.After, - dateRange: [this._max], - }); - } - if (this._disabledDates?.length) { - dates.push(...this.disabledDates); - } + //#endregion + + //#region Public methods - this._dateConstraints = dates.length ? dates : undefined; + /** Clears the input part of the component of any user input */ + public clear(): void { + this.value = null; + this._input?.clear(); } - private renderClearIcon() { + /** Increments the passed in date part */ + public stepUp(datePart?: DatePart, delta?: number): void { + this._input.stepUp(datePart, delta); + } + + /** Decrements the passed in date part */ + public stepDown(datePart?: DatePart, delta?: number): void { + this._input.stepDown(datePart, delta); + } + + /** Selects the text in the input of the component */ + public select(): void { + this._input.select(); + } + + /** Sets the text selection range in the input of the component */ + public setSelectionRange( + start: number, + end: number, + direction?: SelectionRangeDirection + ): void { + this._input.setSelectionRange(start, end, direction); + } + + /* Replaces the selected text in the input and re-applies the mask */ + public setRangeText( + replacement: string, + start: number, + end: number, + mode?: RangeTextSelectMode + ): void { + this._input.setRangeText(replacement, start, end, mode); + this.value = this._input.value; + } + + //#endregion + + //#region Render methods + + private _renderClearIcon() { return !this.value ? nothing : html` - + `; @@ -647,19 +687,19 @@ export default class IgcDatePickerComponent extends FormAssociatedRequiredMixin( slot="prefix" part=${state} @pointerdown=${this.handlerCalendarIconSlotPointerDown} - @click=${this.handleAnchorClick} + @click=${this.readOnly ? nothing : this.handleAnchorClick} > ${defaultIcon} `; } - private renderCalendarSlots() { - if (this.isDropDown) { + private _renderCalendarSlots() { + if (this._isDropDown) { return nothing; } - const hasHeaderDate = this.headerDateSlotItems.length ? 'header-date' : ''; + const hasHeaderDate = isEmpty(this._headerSlotItems) ? '' : 'header-date'; return html` @@ -669,8 +709,8 @@ export default class IgcDatePickerComponent extends FormAssociatedRequiredMixin( `; } - private renderCalendar(id: string) { - const hideHeader = this.isDropDown ? true : this.hideHeader; + private _renderCalendar(id: string) { + const hideHeader = this._isDropDown ? true : this.hideHeader; return html` - ${this.renderCalendarSlots()} + ${this._renderCalendarSlots()} `; } - protected renderActions() { - const slot = this.isDropDown || !this.actions.length ? undefined : 'footer'; + protected _renderActions() { + const hasActions = !isEmpty(this._actions); + const slot = this._isDropDown || hasActions ? undefined : 'footer'; // If in dialog mode use the dialog footer slot return html` -
+
`; } - protected renderPicker(id: string) { - return this.isDropDown + protected _renderPicker(id: string) { + return this._isDropDown ? html` - ${this.renderCalendar(id)}${this.renderActions()} + ${this._renderCalendar(id)}${this._renderActions()} ` @@ -732,37 +769,45 @@ export default class IgcDatePickerComponent extends FormAssociatedRequiredMixin( ?open=${this.open} ?close-on-outside-click=${!this.keepOpenOnOutsideClick} hide-default-action - @igcClosing=${this.handleDialogClosing} - @igcClosed=${this.handleDialogClosed} + @igcClosing=${this._handleDialogClosing} + @igcClosed=${this._handleDialogClosed} exportparts="base: dialog-base, title, footer, overlay" > - ${this.renderCalendar(id)}${this.renderActions()} + ${this._renderCalendar(id)}${this._renderActions()} `; } - private renderLabel(id: string) { + private _renderLabel(id: string) { + const isDisabled = this._isDropDown || this.readOnly; + return this.label - ? html`` + ? html` + + ` : nothing; } - private renderHelperText(): TemplateResult { + private _renderHelperText(): TemplateResult { return IgcValidationContainerComponent.create(this); } - protected renderInput(id: string) { + protected _renderInput(id: string) { const format = formats.has(this._displayFormat!) ? `${this._displayFormat}Date` : this._displayFormat; // Dialog mode is always readonly, rest depends on configuration - const readOnly = !this.isDropDown || this.readOnly || this.nonEditable; + const readOnly = !this._isDropDown || this.readOnly || this.nonEditable; + + const prefix = isEmpty(this._prefixes) ? undefined : 'prefix'; + const suffix = isEmpty(this._suffixes) ? undefined : 'suffix'; return html` - ${this.renderCalendarIcon()} - - ${this.renderClearIcon()} - + ${this._renderCalendarIcon()} + + ${this._renderClearIcon()} + `; } protected override render() { - const id = this.id || this.inputId; + const id = this.id || this._inputId; return html` - ${!this._isMaterialTheme ? this.renderLabel(id) : nothing} - ${this.renderInput(id)}${this.renderPicker(id)}${this.renderHelperText()} + ${!this._isMaterialTheme ? this._renderLabel(id) : nothing} + ${this._renderInput(id)}${this._renderPicker( + id + )}${this._renderHelperText()} `; } + + //#endregion } declare global { From 6986bbaccc30cfe3d218f145adbb48564119494c Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Mon, 19 May 2025 11:12:33 +0300 Subject: [PATCH 2/2] refactor: Address PR review comments --- src/components/date-picker/date-picker.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index af18bfeec..1488a93c7 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -534,7 +534,7 @@ export default class IgcDatePickerComponent extends FormAssociatedRequiredMixin( } } - protected handlerCalendarIconSlotPointerDown(event: PointerEvent) { + protected _handlerCalendarIconSlotPointerDown(event: PointerEvent) { // This is where the delegateFocus of the underlying input is a chore. // If we have a required validator we don't want the input to enter an invalid // state right off the bat when opening the picker which will happen since focus is transferred to the calendar element. @@ -686,7 +686,7 @@ export default class IgcDatePickerComponent extends FormAssociatedRequiredMixin( ${defaultIcon}