Skip to content

Commit

Permalink
feat(datepicker): add validity state
Browse files Browse the repository at this point in the history
  • Loading branch information
aesteves60 authored and dpellier committed Nov 28, 2024
1 parent 8a74238 commit 7bbfdd1
Show file tree
Hide file tree
Showing 10 changed files with 619 additions and 52 deletions.
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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);
Expand All @@ -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: {
Expand All @@ -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;
Expand All @@ -63,17 +68,37 @@ 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<void>;
@Event() odsChange!: EventEmitter<OdsDatepickerChangeEventDetail>;
@Event() odsClear!: EventEmitter<void>;
@Event() odsFocus!: EventEmitter<void>;
@Event() odsReset!: EventEmitter<void>;

@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<boolean> {
this.isInvalid = !this.internals.validity.valid;
return this.internals.checkValidity();
}

@Method()
public async clear(): Promise<void> {
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();
Expand All @@ -84,9 +109,20 @@ export class OdsDatepicker {
this.datepickerInstance?.hide();
}

@Method()
public async getValidationMessage(): Promise<string> {
return this.internals.validationMessage;
}

@Method()
public async getValidity(): Promise<ValidityState | undefined> {
return this.inputElement?.validity;
return this.internals.validity;
}

@Method()
public async reportValidity(): Promise<boolean> {
this.isInvalid = !this.internals.validity.valid;
return this.internals.reportValidity();
}

@Method()
Expand All @@ -98,14 +134,24 @@ export class OdsDatepicker {
public async reset(): Promise<void> {
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<boolean> {
return this.internals.willValidate;
}

@Watch('datesDisabled')
onDatesDisabledChange(): void {
this.datepickerInstance?.setOptions({ datesDisabled: this.datesDisabled?.map((date) => Datepicker.formatDate(date, this.format)) });
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<void> {
Expand All @@ -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 (
<Host class="ods-datepicker">
<Host class="ods-datepicker"
disabled={ this.isDisabled }
readonly={ this.isReadonly }>
<input
aria-label={ this.ariaLabel }
aria-labelledby={ this.ariaLabelledby }
class={{
'ods-datepicker__input': true,
'ods-datepicker__input--clearable': hasClearableAction,
'ods-datepicker__input--error': this.hasError,
'ods-datepicker__input--error': this.hasError || this.isInvalid,
'ods-datepicker__input--loading': this.isLoading,
}}
disabled={ this.isDisabled }
name={ this.name }
onBlur={ () => this.onBlur() }
onFocus={ () => this.odsFocus.emit() }
placeholder={ this.placeholder }
readonly={ this.isReadonly }
ref={ (el): HTMLInputElement => this.inputElement = el as HTMLInputElement }
Expand Down
Original file line number Diff line number Diff line change
@@ -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)) {
Expand All @@ -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,
};
35 changes: 18 additions & 17 deletions packages/ods/src/components/datepicker/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
</head>

<body style="margin-bottom: 200px;">
<p>Default</p>
<!-- <p>Default</p>
<ods-datepicker>
</ods-datepicker>
Expand Down Expand Up @@ -114,62 +114,63 @@
<script>
const defaultValueDatepicker = document.querySelector('#default-value');
defaultValueDatepicker.defaultValue = new Date('04/21/2024');
</script>
</script> -->

<p>Methods & Events</p>
<ods-datepicker id="datepicker-methods">
<ods-datepicker is-required id="datepicker-methods" >
</ods-datepicker>
<button id="datepicker-clear-button">Clear</button>
<button id="datepicker-reset-button">Reset</button>
<button id="datepicker-get-validity-button">GetValidity</button>
<button id="datepicker-open-button">Open</button>
<button id="datepicker-update-value-button">update Value</button>
<button id="datepicker-close-button">Close</button>
<script>
const datepickerMethods = document.getElementById('datepicker-methods');
datepickerMethods.defaultValue = new Date();
// setTimeout(() => datepickerMethods.value = new Date(), 0);
const datepickerClearButton = document.getElementById('datepicker-clear-button');
const datepickerResetButton = document.getElementById('datepicker-reset-button');
const datepickerGetValidityButton = document.getElementById('datepicker-get-validity-button');
const datepickerOpenButton = document.getElementById('datepicker-open-button');
const datepickerUpdateValueButton = document.getElementById('datepicker-update-value-button');
const datepickerCloseButton = document.getElementById('datepicker-close-button');

datepickerClearButton.addEventListener('click', () => datepickerMethods.clear());
datepickerResetButton.addEventListener('click', () => datepickerMethods.reset());
datepickerOpenButton.addEventListener('click', () => datepickerMethods.open());
datepickerUpdateValueButton.addEventListener('click', () => datepickerMethods.value = new Date());
datepickerCloseButton.addEventListener('click', () => datepickerMethods.close());
datepickerGetValidityButton.addEventListener('click', async() => {
console.log(await datepickerMethods.getValidity());
});

// datepickerMethods.addEventListener('odsBlur', () => { console.log('on blur') });
datepickerMethods.addEventListener('odsBlur', () => { console.log('on blur') });
datepickerMethods.addEventListener('odsChange', (e) => { console.log('on change', e) });
datepickerMethods.addEventListener('odsClear', () => { console.log('on clear') });
// datepickerMethods.addEventListener('odsFocus', () => { console.log('on focus') });
datepickerMethods.addEventListener('odsFocus', () => { console.log('on focus') });
datepickerMethods.addEventListener('odsReset', () => { console.log('on reset') });
</script>

<p>Form</p>
<form id="datepicker-form">
<ods-datepicker id="datepicker-in-form"
name="datepicker">
<!-- <p>Form</p>
<form id="datepicker-form" onsubmit="return false">
<ods-datepicker is-required id="datepicker-in-form"
name="datepicker"
default-value="14/10/2024">
</ods-datepicker>
<button id="datepicker-form-reset-button" type="reset">Reset</button>
<button id="datepicker-form-submit-button" type="submit">Submit</button>
</form>
<script>
const datepickerFormElement = document.querySelector('#datepicker-in-form');
// datepickerFormElement.value = new Date();
datepickerFormElement.defaultValue = new Date();
// datepickerFormElement.defaultValue = new Date();
const datepickerForm = document.querySelector('#datepicker-form');
const datepickerSubmitFormButton = document.querySelector('#datepicker-form-submit-button');
datepickerSubmitFormButton.addEventListener('click', () => {
const formData = new FormData(datepickerForm);
console.log('formData', formData);
});
</script>
</script> -->

<p>Dynamic Prop Change</p>
<!-- <p>Dynamic Prop Change</p>
<ods-datepicker id="dynamic-datepicker"></ods-datepicker>
<button id="dynamic-datepicker-change-prop-button" type="button">Change Prop</button>
<script>
Expand All @@ -186,6 +187,6 @@
<p>With label</p>
<label for="labelled-datepicker">My datepicker</label>
<ods-datepicker id="labelled-datepicker">
</ods-datepicker>
</ods-datepicker> -->
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,8 @@ describe('ods-datepicker behaviour', () => {

describe('reset', () => {
it('should emit an odsReset event', async() => {
await setup('<ods-datepicker></ods-datepicker>');
await setup('<ods-datepicker default-value="14/10/2024"></ods-datepicker>');
await page.evaluate(() => {
document.querySelector<OdsDatepicker & HTMLElement>('ods-datepicker')!.defaultValue = new Date(0);
document.querySelector<OdsDatepicker & HTMLElement>('ods-datepicker')!.value = new Date();
});
await page.waitForChanges();
Expand All @@ -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('<ods-datepicker is-disabled></ods-datepicker>');
await setup('<ods-datepicker is-disabled default-value="14/10/2024"></ods-datepicker>');
await page.evaluate(() => {
document.querySelector<OdsDatepicker & HTMLElement>('ods-datepicker')!.defaultValue = new Date(0);
document.querySelector<OdsDatepicker & HTMLElement>('ods-datepicker')!.value = new Date();
});
await page.waitForChanges();
Expand All @@ -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);
});
});
Expand Down
Loading

0 comments on commit 7bbfdd1

Please sign in to comment.