From 7bbfdd151c57994902d2296727ef6135a9add377 Mon Sep 17 00:00:00 2001 From: Alexandre Esteves Date: Wed, 18 Sep 2024 15:30:02 +0200 Subject: [PATCH] feat(datepicker): add validity state --- .../ods-datepicker/ods-datepicker.tsx | 118 +++++-- .../src/controller/ods-datepicker.ts | 9 +- .../src/components/datepicker/src/index.html | 35 +- .../tests/behaviour/ods-datepicker.e2e.ts | 10 +- .../tests/controller/ods-datepicker.spec.ts | 12 +- .../tests/rendering/ods-datepicker.e2e.ts | 60 ++++ .../tests/rendering/ods-datepicker.spec.ts | 6 + .../tests/validity/ods-datepicker.e2e.ts | 330 ++++++++++++++++++ .../datepicker/datepicker.stories.ts | 87 ++++- .../datepicker/technical-information.mdx | 4 + 10 files changed, 619 insertions(+), 52 deletions(-) create mode 100644 packages/ods/src/components/datepicker/tests/validity/ods-datepicker.e2e.ts diff --git a/packages/ods/src/components/datepicker/src/components/ods-datepicker/ods-datepicker.tsx b/packages/ods/src/components/datepicker/src/components/ods-datepicker/ods-datepicker.tsx index 50bc251146..4ef6f4a541 100644 --- a/packages/ods/src/components/datepicker/src/components/ods-datepicker/ods-datepicker.tsx +++ b/packages/ods/src/components/datepicker/src/components/ods-datepicker/ods-datepicker.tsx @@ -1,4 +1,4 @@ -import { AttachInternals, Component, Element, Event, type EventEmitter, type FunctionalComponent, Host, Method, Prop, Watch, h } from '@stencil/core'; +import { AttachInternals, Component, Element, Event, type EventEmitter, type FunctionalComponent, Host, Listen, Method, Prop, State, Watch, h } from '@stencil/core'; import { Datepicker } from 'vanillajs-datepicker'; // @ts-ignore no existing declaration import de from 'vanillajs-datepicker/js/i18n/locales/de'; // eslint-disable-line import/no-unresolved @@ -19,7 +19,7 @@ import { ODS_ICON_NAME } from '../../../../icon/src'; import { ODS_SPINNER_COLOR } from '../../../../spinner/src'; import { type OdsDatepickerDay } from '../../constants/datepicker-day'; import { ODS_DATEPICKER_LOCALE, type OdsDatepickerLocale } from '../../constants/datepicker-locale'; -import { formatDate, setFormValue } from '../../controller/ods-datepicker'; +import { formatDate, updateInternals } from '../../controller/ods-datepicker'; import { type OdsDatepickerChangeEventDetail } from '../../interfaces/events'; Object.assign(Datepicker.locales, de); @@ -30,6 +30,8 @@ Object.assign(Datepicker.locales, nl); Object.assign(Datepicker.locales, pl); Object.assign(Datepicker.locales, pt); +const VALUE_DEFAULT_VALUE = null; + @Component({ formAssociated: true, shadow: { @@ -41,16 +43,19 @@ Object.assign(Datepicker.locales, pt); export class OdsDatepicker { private datepickerInstance?: Datepicker; private inputElement?: HTMLInputElement; + private shouldUpdateIsInvalidState: boolean = false; @Element() el!: HTMLElement; @AttachInternals() internals!: ElementInternals; + @State() private isInvalid: boolean = false; + @Prop({ reflect: true }) public ariaLabel: HTMLElement['ariaLabel'] = null; @Prop({ reflect: true }) public ariaLabelledby?: string; @Prop({ reflect: true }) public datesDisabled: Date[] = []; @Prop({ reflect: true }) public daysOfWeekDisabled: OdsDatepickerDay[] = []; - @Prop({ reflect: true }) public defaultValue?: Date; + @Prop({ reflect: true }) public defaultValue?: string; @Prop({ reflect: true }) public format: string = 'dd/mm/yyyy'; @Prop({ reflect: true }) public hasError: boolean = false; @Prop({ reflect: true }) public isClearable: boolean = false; @@ -63,7 +68,7 @@ export class OdsDatepicker { @Prop({ reflect: true }) public min?: Date; @Prop({ reflect: true }) public name!: string; @Prop({ reflect: true }) public placeholder?: string; - @Prop({ mutable: true, reflect: true }) public value: Date | null = null; + @Prop({ mutable: true, reflect: true }) public value: Date | null = VALUE_DEFAULT_VALUE; @Event() odsBlur!: EventEmitter; @Event() odsChange!: EventEmitter; @@ -71,9 +76,29 @@ export class OdsDatepicker { @Event() odsFocus!: EventEmitter; @Event() odsReset!: EventEmitter; + @Listen('invalid') + onInvalidEvent(event: Event): void { + // Remove the native validation message popup + event.preventDefault(); + + // Enforce the state here as we may still be in pristine state (if the form is submitted before any changes occurs) + this.isInvalid = true; + } + + @Method() + async checkValidity(): Promise { + this.isInvalid = !this.internals.validity.valid; + return this.internals.checkValidity(); + } + @Method() public async clear(): Promise { this.odsClear.emit(); + + // Element internal validityState is not yet updated, so we set the flag + // to update our internal state when it will be up-to-date + this.shouldUpdateIsInvalidState = true; + // This will trigger the "changeDate" event that will take care of updating value, internals and emit change this.datepickerInstance?.setDate({ clear: true }); this.inputElement?.focus(); @@ -84,9 +109,20 @@ export class OdsDatepicker { this.datepickerInstance?.hide(); } + @Method() + public async getValidationMessage(): Promise { + return this.internals.validationMessage; + } + @Method() public async getValidity(): Promise { - return this.inputElement?.validity; + return this.internals.validity; + } + + @Method() + public async reportValidity(): Promise { + this.isInvalid = !this.internals.validity.valid; + return this.internals.reportValidity(); } @Method() @@ -98,14 +134,24 @@ export class OdsDatepicker { public async reset(): Promise { this.odsReset.emit(); + // Element internal validityState is not yet updated, so we set the flag + // to update our internal state when it will be up-to-date + this.shouldUpdateIsInvalidState = true; + // Those will trigger the "changeDate" event that will take care of updating value, internals and emit change if (this.defaultValue) { - this.datepickerInstance?.setDate(this.defaultValue); + const defaultValue = new Date(Datepicker.parseDate(this.defaultValue, this.format)); + this.datepickerInstance?.setDate(defaultValue); } else { this.datepickerInstance?.setDate({ clear: true }); } } + @Method() + async willValidate(): Promise { + return this.internals.willValidate; + } + @Watch('datesDisabled') onDatesDisabledChange(): void { this.datepickerInstance?.setOptions({ datesDisabled: this.datesDisabled?.map((date) => Datepicker.formatDate(date, this.format)) }); @@ -136,16 +182,22 @@ export class OdsDatepicker { this.datepickerInstance?.setOptions({ minDate: this.min }); } + @Watch('value') + onValueChangeFromJs(): void { + this.value && this.datepickerInstance?.setDate(new Date(this.value.toDateString())); + } + componentWillLoad(): void { // Those components are used in some string templates, not JSX, // thus Stencil does not detect them correctly and they're not embedded in the build, // so we have to manually declare their usage here h('ods-icon'); - if (!this.value) { - this.value = this.defaultValue ? new Date(this.defaultValue) : null; + // We set the value before the observer starts to avoid calling the mutation callback twice + // as it will be called on componentDidLoad (when native element validity is up-to-date) + if (!this.value && this.value !== 0 && (this.value !== VALUE_DEFAULT_VALUE || this.defaultValue)) { + this.value = this.defaultValue ? new Date(Datepicker.parseDate(this.defaultValue, this.format)) : null; } - setFormValue(this.internals, formatDate(this.value, this.format)); } componentDidLoad(): void { @@ -184,21 +236,19 @@ export class OdsDatepicker { // Triggered either by user selection or `setDate` instance method call this.inputElement.addEventListener('changeDate', (event: Event) => { const newDate: Date = (event as CustomEvent).detail.date; + // console.log('changeDate', (event as CustomEvent).detail, newDate, this.value); const formattedNewDate = formatDate(newDate, this.format); const previousValue = this.value; + // console.log('changeDate newDate', newDate instanceof Date ? newDate.toISOString() : newDate); + // console.log('changeDate previousValue', previousValue instanceof Date ? previousValue.toISOString() : previousValue); this.value = newDate ?? null; - setFormValue(this.internals, formattedNewDate); - - this.odsChange.emit({ - formattedValue: formattedNewDate, - name: this.name, - previousValue, - validity: this.inputElement?.validity, - value: this.value, - }); + this.onValueChange(formattedNewDate, previousValue); }); } + + // Init the internals correctly as native element validity is now up-to-date + this.onValueChange(formatDate(this.value, this.format)); } async formResetCallback(): Promise { @@ -223,22 +273,50 @@ export class OdsDatepicker { } } + private onBlur(): void { + this.isInvalid = !this.internals.validity.valid; + this.odsBlur.emit(); + } + + private onValueChange(formattedValue: string, previousValue?: Date | null): void { + updateInternals(this.internals, formattedValue, this.inputElement); + + // In case the value gets updated from an other source than a blur event + // we may have to perform an internal validity state update + if (this.shouldUpdateIsInvalidState) { + this.isInvalid = !this.internals.validity.valid; + this.shouldUpdateIsInvalidState = false; + } + + this.odsChange.emit({ + formattedValue, + name: this.name, + previousValue, + validity: this.internals.validity, + value: this.value, + }); + } + render(): FunctionalComponent { const hasClearableAction = this.isClearable && !this.isLoading && !!this.value; return ( - + this.onBlur() } + onFocus={ () => this.odsFocus.emit() } placeholder={ this.placeholder } readonly={ this.isReadonly } ref={ (el): HTMLInputElement => this.inputElement = el as HTMLInputElement } diff --git a/packages/ods/src/components/datepicker/src/controller/ods-datepicker.ts b/packages/ods/src/components/datepicker/src/controller/ods-datepicker.ts index 9c1eb13882..83a1fe8339 100644 --- a/packages/ods/src/components/datepicker/src/controller/ods-datepicker.ts +++ b/packages/ods/src/components/datepicker/src/controller/ods-datepicker.ts @@ -1,4 +1,5 @@ import { Datepicker } from 'vanillajs-datepicker'; +import { setInternalsValidityFromHtmlElement } from '../../../../utils/dom'; function formatDate(date: Date | null, format: string): string { if (date && isDate(date)) { @@ -12,11 +13,15 @@ function isDate(date: Date): boolean { return date instanceof Date && !isNaN(date.valueOf()); } -function setFormValue(internals: ElementInternals, value: string | null): void { +function updateInternals(internals: ElementInternals, value: number | string | null, inputEl?: HTMLInputElement): void { internals.setFormValue(value?.toString() ?? ''); + + if (inputEl) { + setInternalsValidityFromHtmlElement(inputEl, internals); + } } export { formatDate, - setFormValue, + updateInternals, }; diff --git a/packages/ods/src/components/datepicker/src/index.html b/packages/ods/src/components/datepicker/src/index.html index ca9e16602a..de4cbb7161 100644 --- a/packages/ods/src/components/datepicker/src/index.html +++ b/packages/ods/src/components/datepicker/src/index.html @@ -17,7 +17,7 @@ -

Default

+

Methods & Events

- + - + -

Form

-
- + -

Dynamic Prop Change

+ diff --git a/packages/ods/src/components/datepicker/tests/behaviour/ods-datepicker.e2e.ts b/packages/ods/src/components/datepicker/tests/behaviour/ods-datepicker.e2e.ts index e7576ef166..d99635b8b9 100644 --- a/packages/ods/src/components/datepicker/tests/behaviour/ods-datepicker.e2e.ts +++ b/packages/ods/src/components/datepicker/tests/behaviour/ods-datepicker.e2e.ts @@ -79,9 +79,8 @@ describe('ods-datepicker behaviour', () => { describe('reset', () => { it('should emit an odsReset event', async() => { - await setup(''); + await setup(''); await page.evaluate(() => { - document.querySelector('ods-datepicker')!.defaultValue = new Date(0); document.querySelector('ods-datepicker')!.value = new Date(); }); await page.waitForChanges(); @@ -90,14 +89,13 @@ describe('ods-datepicker behaviour', () => { await el.callMethod('reset'); await page.waitForChanges(); - expect(await page.evaluate(() => document.querySelector('ods-datepicker')?.shadowRoot?.querySelector('input')?.value)).toBe('01/01/1970'); + expect(await page.evaluate(() => document.querySelector('ods-datepicker')?.shadowRoot?.querySelector('input')?.value)).toBe('14/10/2024'); expect(odsResetSpy).toHaveReceivedEventTimes(1); }); it('should emit an odsReset event even if disabled', async() => { - await setup(''); + await setup(''); await page.evaluate(() => { - document.querySelector('ods-datepicker')!.defaultValue = new Date(0); document.querySelector('ods-datepicker')!.value = new Date(); }); await page.waitForChanges(); @@ -106,7 +104,7 @@ describe('ods-datepicker behaviour', () => { await el.callMethod('reset'); await page.waitForChanges(); - expect(await page.evaluate(() => document.querySelector('ods-datepicker')?.shadowRoot?.querySelector('input')?.value)).toBe('01/01/1970'); + expect(await page.evaluate(() => document.querySelector('ods-datepicker')?.shadowRoot?.querySelector('input')?.value)).toBe('14/10/2024'); expect(odsResetSpy).toHaveReceivedEventTimes(1); }); }); diff --git a/packages/ods/src/components/datepicker/tests/controller/ods-datepicker.spec.ts b/packages/ods/src/components/datepicker/tests/controller/ods-datepicker.spec.ts index d5174536ec..aa8979be7f 100644 --- a/packages/ods/src/components/datepicker/tests/controller/ods-datepicker.spec.ts +++ b/packages/ods/src/components/datepicker/tests/controller/ods-datepicker.spec.ts @@ -5,7 +5,7 @@ jest.mock('vanillajs-datepicker', () => ({ })); import { Datepicker } from 'vanillajs-datepicker'; -import { formatDate, setFormValue } from '../../src/controller/ods-datepicker'; +import { formatDate, updateInternals } from '../../src/controller/ods-datepicker'; describe('ods-datepicker controller', () => { beforeEach(jest.clearAllMocks); @@ -39,28 +39,28 @@ describe('ods-datepicker controller', () => { }); }); - describe('setFormValue', () => { + describe('updateInternals', () => { const dummyInternal = { setFormValue: jest.fn(), } as unknown as ElementInternals; it('should set internal value with empty string', () => { // @ts-ignore for test purpose - setFormValue(dummyInternal); + updateInternals(dummyInternal); expect(dummyInternal.setFormValue).toHaveBeenCalledWith(''); // @ts-ignore for test purpose - setFormValue(dummyInternal, undefined); + updateInternals(dummyInternal, undefined); expect(dummyInternal.setFormValue).toHaveBeenCalledWith(''); - setFormValue(dummyInternal, null); + updateInternals(dummyInternal, null); expect(dummyInternal.setFormValue).toHaveBeenCalledWith(''); }); it('should set internal value with string value', () => { const dummyValue = 'dummy value'; - setFormValue(dummyInternal, dummyValue); + updateInternals(dummyInternal, dummyValue); expect(dummyInternal.setFormValue).toHaveBeenCalledWith(dummyValue); }); diff --git a/packages/ods/src/components/datepicker/tests/rendering/ods-datepicker.e2e.ts b/packages/ods/src/components/datepicker/tests/rendering/ods-datepicker.e2e.ts index 87e1e227de..2e94c428fb 100644 --- a/packages/ods/src/components/datepicker/tests/rendering/ods-datepicker.e2e.ts +++ b/packages/ods/src/components/datepicker/tests/rendering/ods-datepicker.e2e.ts @@ -71,4 +71,64 @@ describe('ods-datepicker rendering', () => { }); expect(allDisabled).toBe(true); }); + + describe('error state', () => { + it('should render in error on form submit, before any changes, if invalid', async() => { + await setup(''); + + await page.evaluate(() => { + document.querySelector('form')?.requestSubmit(); + }); + await page.waitForChanges(); + + const hasErrorClass = await page.evaluate(() => { + return document.querySelector('ods-datepicker')?.shadowRoot?.querySelector('input')?.classList.contains('ods-datepicker__input--error'); + }); + expect(hasErrorClass).toBe(true); + }); + + it('should toggle the error state on value change', async() => { + await setup('
'); + + await page.evaluate(() => { + document.querySelector('ods-datepicker')!.value = new Date('10 May 2024'); + }); + await page.waitForChanges(); + + const hasErrorClass = await page.evaluate(() => { + return document.querySelector('ods-datepicker')?.shadowRoot?.querySelector('input')?.classList.contains('ods-datepicker__input--error'); + }); + expect(hasErrorClass).toBe(false); + + await el.callMethod('clear'); + await page.click('body', { offset: { x: 400, y: 400 } }); // Blur + await page.waitForChanges(); + + const hasErrorClass2 = await page.evaluate(() => { + return document.querySelector('ods-datepicker')?.shadowRoot?.querySelector('input')?.classList.contains('ods-datepicker__input--error'); + }); + await page.waitForChanges(); + expect(hasErrorClass2).toBe(true); + }); + + it('should enforce the error state if has-error is set even on valid datepicker', async() => { + await setup('
'); + await page.waitForChanges(); + + const hasErrorClass = await page.evaluate(() => { + return document.querySelector('ods-datepicker')?.shadowRoot?.querySelector('input')?.classList.contains('ods-datepicker__input--error'); + }); + expect(hasErrorClass).toBe(true); + + await page.evaluate(() => { + document.querySelector('form')?.requestSubmit(); + }); + await page.waitForChanges(); + + const hasErrorClass2 = await page.evaluate(() => { + return document.querySelector('ods-datepicker')?.shadowRoot?.querySelector('input')?.classList.contains('ods-datepicker__input--error'); + }); + expect(hasErrorClass2).toBe(true); + }); + }); }); diff --git a/packages/ods/src/components/datepicker/tests/rendering/ods-datepicker.spec.ts b/packages/ods/src/components/datepicker/tests/rendering/ods-datepicker.spec.ts index ec97d295cb..94caed4d42 100644 --- a/packages/ods/src/components/datepicker/tests/rendering/ods-datepicker.spec.ts +++ b/packages/ods/src/components/datepicker/tests/rendering/ods-datepicker.spec.ts @@ -4,6 +4,12 @@ import type { SpecPage } from '@stencil/core/testing'; import { newSpecPage } from '@stencil/core/testing'; import { OdsDatepicker } from '../../src'; +// @ts-ignore for test purposes +global.MutationObserver = jest.fn(() => ({ + disconnect: jest.fn(), + observe: jest.fn(), +})); + describe('ods-datepicker rendering', () => { let page: SpecPage; let root: HTMLElement | undefined; diff --git a/packages/ods/src/components/datepicker/tests/validity/ods-datepicker.e2e.ts b/packages/ods/src/components/datepicker/tests/validity/ods-datepicker.e2e.ts new file mode 100644 index 0000000000..26af2618f9 --- /dev/null +++ b/packages/ods/src/components/datepicker/tests/validity/ods-datepicker.e2e.ts @@ -0,0 +1,330 @@ +import { type E2EElement, type E2EPage, newE2EPage } from '@stencil/core/testing'; +import { type OdsDatepicker } from '../../src'; + +describe('ods-datepicker validity', () => { + let el: E2EElement; + let page: E2EPage; + + async function setup(content: string): Promise { + page = await newE2EPage(); + + await page.setContent(content); + await page.evaluate(() => document.body.style.setProperty('margin', '0px')); + + el = await page.find('ods-datepicker'); + } + + beforeEach(jest.clearAllMocks); + + describe('initialization', () => { + describe('with no value attribute defined', () => { + it('should return validity true if not required', async() => { + await setup(''); + + expect(await el.callMethod('checkValidity')).toBe(true); + }); + + it('should return validity false if required', async() => { + await setup(''); + + expect(await el.callMethod('checkValidity')).toBe(false); + }); + }); + + describe('with empty string value', () => { + it('should return validity true if not required', async() => { + await setup(''); + + expect(await el.callMethod('checkValidity')).toBe(true); + }); + + it('should return validity false if required', async() => { + await setup(''); + + expect(await el.callMethod('checkValidity')).toBe(false); + }); + }); + + describe('with no value but default-value defined', () => { + it('should return validity true if not required', async() => { + await setup(''); + await page.waitForChanges(); + expect(await el.callMethod('checkValidity')).toBe(true); + }); + + it('should return validity true if required', async() => { + await setup(''); + + await page.waitForChanges(); + expect(await el.callMethod('checkValidity')).toBe(true); + }); + }); + + describe('with defined value', () => { + it('should return validity true if not required', async() => { + await setup(''); + await page.evaluate(() => { + document.querySelector('ods-datepicker')!.value = new Date(0); + }); + await page.waitForChanges(); + expect(await el.callMethod('checkValidity')).toBe(true); + }); + + it('should return validity true if required', async() => { + await setup(''); + await page.evaluate(() => { + document.querySelector('ods-datepicker')!.value = new Date(0); + }); + await page.waitForChanges(); + expect(await el.callMethod('checkValidity')).toBe(true); + }); + }); + }); + + describe('methods', () => { + describe('checkValidity', () => { + it('should return true if internals validity is true', async() => { + await setup(''); + expect(await el.callMethod('checkValidity')).toBe(true); + + await setup(''); + await page.waitForChanges(); + await page.evaluate(async() => { + const datepicker = document.querySelector('ods-datepicker'); + datepicker!.value = new Date(0); + }); + expect(await el.callMethod('checkValidity')).toBe(true); + + await setup(''); + await page.waitForChanges(); + expect(await el.callMethod('checkValidity')).toBe(true); + }); + + it('should return false if internals validity is false', async() => { + await setup(''); + expect(await el.callMethod('checkValidity')).toBe(false); + + await setup(''); + expect(await el.callMethod('checkValidity')).toBe(false); + + await setup(''); + expect(await el.callMethod('checkValidity')).toBe(false); + }); + }); + + describe('clear', () => { + it('should update the validity state accordingly, given value', async() => { + await setup(''); + await page.evaluate(() => { + document.querySelector('ods-datepicker')!.value = new Date(0); + }); + await page.waitForChanges(); + expect(await el.callMethod('checkValidity')).toBe(true); + + await el.callMethod('clear'); + await page.waitForChanges(); + + expect(await el.callMethod('checkValidity')).toBe(false); + }); + + it('should update the validity state accordingly, given default-value', async() => { + await setup(''); + await page.waitForChanges(); + expect(await el.callMethod('checkValidity')).toBe(true); + + await el.callMethod('clear'); + await page.waitForChanges(); + + expect(await el.callMethod('checkValidity')).toBe(false); + }); + }); + + describe('getValidationMessage', () => { + it('should return empty element validation message if valid', async() => { + await setup(''); + expect(await el.callMethod('getValidationMessage')).toBe(''); + + await setup(''); + await page.evaluate(async() => { + document.querySelector('ods-datepicker')!.value = new Date(0); + }); + await page.waitForChanges(); + expect(await el.callMethod('getValidationMessage')).toBe(''); + + await setup(''); + await page.waitForChanges(); + expect(await el.callMethod('getValidationMessage')).toBe(''); + }); + + it('should return the element validation message if not valid', async() => { + await setup(''); + expect(await el.callMethod('getValidationMessage')).not.toBe(''); + + await setup(''); + expect(await el.callMethod('getValidationMessage')).not.toBe(''); + + await setup(''); + expect(await el.callMethod('getValidationMessage')).not.toBe(''); + }); + }); + + describe('getValidity', () => { + // el.callMethod('getValidity') does not return the ValidityState instance, we have to use an evaluate + // and return a single prop, otherwise it'll return an empty object + async function getValidityProp(prop: keyof ValidityState): Promise { + return await page.evaluate(async(validityProp): Promise => { + const validityState = await (document.querySelector('ods-datepicker') as unknown as OdsDatepicker)?.getValidity(); + + if (validityState) { + return validityState[validityProp]; + } + return null; + }, prop); + } + + it('should return valid validity state if valid', async() => { + await setup(''); + expect(await getValidityProp('valid')).toBe(true); + + await setup(''); + await page.evaluate(() => { + document.querySelector('ods-datepicker')!.value = new Date(0); + }); + await page.waitForChanges(); + expect(await getValidityProp('valid')).toBe(true); + + await setup(''); + await page.waitForChanges(); + expect(await getValidityProp('valid')).toBe(true); + }); + + it('should return invalid validity state if not valid', async() => { + await setup(''); + expect(await getValidityProp('valid')).toBe(false); + expect(await getValidityProp('valueMissing')).toBe(true); + + await setup(''); + expect(await getValidityProp('valid')).toBe(false); + expect(await getValidityProp('valueMissing')).toBe(true); + + await setup(''); + expect(await getValidityProp('valid')).toBe(false); + expect(await getValidityProp('valueMissing')).toBe(true); + }); + }); + + describe('reportValidity', () => { + it('should return true if internals validity is true', async() => { + await setup(''); + expect(await el.callMethod('reportValidity')).toBe(true); + + await setup(''); + await page.evaluate(() => { + document.querySelector('ods-datepicker')!.value = new Date(0); + }); + await page.waitForChanges(); + expect(await el.callMethod('reportValidity')).toBe(true); + + await setup(''); + await page.waitForChanges(); + expect(await el.callMethod('reportValidity')).toBe(true); + }); + + it('should return false if internals validity is false', async() => { + await setup(''); + expect(await el.callMethod('reportValidity')).toBe(false); + + await setup(''); + expect(await el.callMethod('reportValidity')).toBe(false); + + await setup(''); + expect(await el.callMethod('reportValidity')).toBe(false); + }); + }); + + describe('reset', () => { + it('should update the validity state accordingly, given value', async() => { + await setup(''); + await page.evaluate(() => { + document.querySelector('ods-datepicker')!.value = new Date(1); + }); + await page.waitForChanges(); + expect(await el.callMethod('checkValidity')).toBe(true); + + await el.callMethod('clear'); + await page.waitForChanges(); + + expect(await el.callMethod('checkValidity')).toBe(false); + + await el.callMethod('reset'); + await page.waitForChanges(); + + expect(await el.callMethod('checkValidity')).toBe(true); + }); + + it('should update the validity state accordingly, given no value', async() => { + await setup(''); + + await page.waitForChanges(); + expect(await el.callMethod('checkValidity')).toBe(true); + + await el.callMethod('clear'); + await page.waitForChanges(); + + expect(await el.callMethod('checkValidity')).toBe(false); + + await el.callMethod('reset'); + await page.waitForChanges(); + + expect(await el.callMethod('checkValidity')).toBe(true); + }); + }); + + describe('willValidate', () => { + it('should return true if element is submittable', async() => { + await setup(''); + expect(await el.callMethod('willValidate')).toBe(true); + }); + + it('should return false if element is not submittable', async() => { + await setup(''); + expect(await el.callMethod('willValidate')).toBe(false); + + await setup(''); + expect(await el.callMethod('willValidate')).toBe(false); + }); + }); + }); + + describe('in a form', () => { + it('should not submit the form on submit before any changes if datepicker is invalid', async() => { + await setup('
'); + + const formValidity = await page.evaluate(() => { + const form = document.querySelector('form'); + form?.requestSubmit(); + return form?.reportValidity(); + }); + + expect(await el.callMethod('checkValidity')).toBe(false); + expect(formValidity).toBe(false); + }); + + it('should submit the form if datepicker is valid', async() => { + await setup('
'); + await page.evaluate(() => { + document.querySelector('ods-datepicker')!.value = new Date(0); + }); + await page.waitForChanges(); + await el.type('abcd'); + const formValidity = await page.evaluate(() => { + const form = document.querySelector('form'); + form?.requestSubmit(); + return form?.reportValidity(); + }); + + expect(await el.callMethod('checkValidity')).toBe(true); + expect(formValidity).toBe(true); + }); + }); +}); diff --git a/packages/storybook/stories/components/datepicker/datepicker.stories.ts b/packages/storybook/stories/components/datepicker/datepicker.stories.ts index 09fb00304f..364ea7643f 100644 --- a/packages/storybook/stories/components/datepicker/datepicker.stories.ts +++ b/packages/storybook/stories/components/datepicker/datepicker.stories.ts @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/web-components'; import { ODS_DATEPICKER_LOCALE, ODS_DATEPICKER_LOCALES } from '@ovhcloud/ods-components'; -import { html } from 'lit-html'; +import { html, nothing } from 'lit-html'; import { CONTROL_CATEGORY } from '../../../src/constants/controls'; import { orderControls } from '../../../src/helpers/controls'; @@ -13,6 +13,47 @@ const meta: Meta = { export default meta; export const Demo: StoryObj = { + render: (arg) => { + const validityStateTemplate = html`
+
+ `; + return html` + + + ${ arg.validityState ? validityStateTemplate : '' } + `; + }, argTypes: orderControls({ ariaLabel: { table: { @@ -86,6 +127,14 @@ export const Demo: StoryObj = { }, control: 'boolean', }, + isRequired: { + table: { + category: CONTROL_CATEGORY.general, + defaultValue: { summary: false }, + type: { summary: 'boolean' }, + }, + control: 'boolean', + }, locale: { table: { category: CONTROL_CATEGORY.general, @@ -119,6 +168,14 @@ export const Demo: StoryObj = { }, control: 'text', }, + validityState: { + table: { + category: CONTROL_CATEGORY.accessibility, + defaultValue: { summary: false }, + type: { summary: 'boolean' }, + }, + control: 'boolean', + }, }), args: { hasError: false, @@ -126,6 +183,8 @@ export const Demo: StoryObj = { isDisabled: false, isLoading: false, isReadonly: false, + isRequired: false, + validityState: false, }, }; @@ -255,3 +314,29 @@ export const Readonly: StoryObj = { `, }; + +export const ValidityState: StoryObj = { + tags: ['isHidden'], + render: () => html` + + +
+ +`, +}; diff --git a/packages/storybook/stories/components/datepicker/technical-information.mdx b/packages/storybook/stories/components/datepicker/technical-information.mdx index d68429a7b9..3135b5706e 100644 --- a/packages/storybook/stories/components/datepicker/technical-information.mdx +++ b/packages/storybook/stories/components/datepicker/technical-information.mdx @@ -86,6 +86,10 @@ Different token from the format: + + + +