Skip to content

Commit

Permalink
feat(timepicker): 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 aa6a2cf commit a7e73ea
Show file tree
Hide file tree
Showing 6 changed files with 430 additions and 24 deletions.
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import type { OdsSelect } from '../../../../select/src';
import type { OdsTimepickerChangeEventDetail } from '../../interfaces/event';
import type { EventEmitter, FunctionalComponent } from '@stencil/core';
import { AttachInternals, Component, Element, Event, Host, Method, Prop, Watch, h } from '@stencil/core';
import { AttachInternals, Component, Element, Event, Host, Listen, Method, Prop, State, Watch, h } from '@stencil/core';
import { submitFormOnEnter } from '../../../../../utils/dom';
import { ODS_INPUT_TYPE, type OdsInput, type OdsInputChangeEvent } from '../../../../input/src';
import { type OdsSelectChangeEvent } from '../../../../select/src';
import { type OdsTimezonePreset } from '../../constant/timezone-preset';
import { type OdsTimezone } from '../../constant/timezones';
import { formatValue, getCurrentTimezone, parseTimezones, setFormValue } from '../../controller/ods-timepicker';
import { formatValue, getCurrentTimezone, parseTimezones, updateInternals } from '../../controller/ods-timepicker';

const VALUE_DEFAULT_VALUE = null;

@Component({
formAssociated: true,
Expand All @@ -29,6 +31,8 @@ export class OdsTimepicker {

@AttachInternals() private internals!: ElementInternals;

@State() isInvalid: boolean = false;

@Prop({ reflect: true }) public ariaLabel: HTMLElement['ariaLabel'] = null;
@Prop({ reflect: true }) public ariaLabelledby?: string;
@Prop({ mutable: true, reflect: true }) public currentTimezone?: OdsTimezone;
Expand All @@ -39,7 +43,7 @@ export class OdsTimepicker {
@Prop({ reflect: true }) public isRequired: boolean = false;
@Prop({ reflect: true }) public name!: string;
@Prop({ reflect: true }) public timezones?: OdsTimezone[] | OdsTimezonePreset | string;
@Prop({ mutable: true, reflect: true }) public value: string | null = null;
@Prop({ mutable: true, reflect: true }) public value: string | null = VALUE_DEFAULT_VALUE;
@Prop({ reflect: true }) public withSeconds: boolean = false;

@Event() odsBlur!: EventEmitter<void>;
Expand All @@ -48,23 +52,54 @@ export class OdsTimepicker {
@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()
public async clear(): Promise<void> {
this.odsInput?.clear();
this.odsSelect?.clear();
async checkValidity(): Promise<boolean> {
return this.internals.checkValidity();
}

@Method()
async clear(): Promise<void> {
await this.odsInput?.clear();
await this.odsSelect?.clear();
this.odsClear.emit();
setTimeout(() => this.isInvalid = !this.internals.validity.valid);
}

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

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

@Method()
public async reset(): Promise<void> {
this.odsInput?.reset();
this.odsSelect?.reset();
async reportValidity(): Promise<boolean> {
return this.internals.reportValidity();
}

@Method()
async reset(): Promise<void> {
await this.odsInput?.reset();
await this.odsSelect?.reset();
this.odsReset.emit();
setTimeout(() => this.isInvalid = !this.internals.validity.valid);
}

@Method()
async willValidate(): Promise<boolean> {
return this.internals.willValidate;
}

@Watch('timezones')
Expand All @@ -75,29 +110,36 @@ export class OdsTimepicker {
}

@Watch('withSeconds')
formatValue(): void {
async formatValue(): Promise<void> {
const value = formatValue(this.odsInput?.value as string, this.withSeconds);

if (value) {
this.previousValue = this.value ?? null;
this.value = value;
}
setFormValue(this.internals, this.value);
await updateInternals(this.internals, this.value, this.odsInput);
}

componentWillLoad(): void {
if (!this.value) {
if (!this.value && (this.value !== VALUE_DEFAULT_VALUE || this.defaultValue)) {
this.value = this.defaultValue ?? null;
}
this.onTimezonesChange();
this.formatValue();
this.defaultCurrentTimezone = this.currentTimezone;
}

async componentDidLoad(): Promise<void> {
this.formatValue();
}

async formResetCallback(): Promise<void> {
await this.reset();
}

private onBlur(): void {
this.isInvalid = !this.internals.validity.valid;
}

private async onOdsChange(event: OdsInputChangeEvent | OdsSelectChangeEvent, isFromSelect: boolean): Promise<void> {
event.preventDefault();
event.stopPropagation();
Expand All @@ -107,32 +149,36 @@ export class OdsTimepicker {
} else {
this.previousValue = event.detail.previousValue as string;
this.value = event.detail.value as string;
setFormValue(this.internals, this.value);
await updateInternals(this.internals, this.value, this.odsInput);
}

this.odsChange.emit({
currentTimezone: this.currentTimezone,
name: this.name,
previousValue: this.previousValue,
validity: await this.odsInput?.getValidity(),
validity: this.internals.validity,
value: this.value,
});
}

render(): FunctionalComponent {
return (
<Host class="ods-timepicker">
<Host class="ods-timepicker"
disabled={ this.isDisabled }
readonly={ this.isReadonly }>
<ods-input
ariaLabel={ this.ariaLabel }
ariaLabelledby={ this.ariaLabelledby }
class="ods-timepicker__input"
defaultValue={ this.defaultValue }
exportparts="input"
hasError={ this.hasError }
hasError={ this.hasError || this.isInvalid }
isDisabled={ this.isDisabled }
isReadonly={ this.isReadonly }
isRequired={ this.isRequired }
name={ this.name }
onKeyUp={ (event: KeyboardEvent): void => submitFormOnEnter(event, this.internals.form) }
onOdsBlur={ () => this.onBlur() }
onOdsChange={ (event: OdsInputChangeEvent) => this.onOdsChange(event, false) }
onOdsClear={ (event: CustomEvent<void>) => event.stopPropagation() }
onOdsReset={ (event: CustomEvent<void>) => event.stopPropagation() }
Expand All @@ -147,10 +193,12 @@ export class OdsTimepicker {
<ods-select
class="ods-timepicker__timezones"
defaultValue={ this.defaultCurrentTimezone }
hasError={ this.hasError }
hasError={ this.hasError || this.isInvalid }
isDisabled={ this.isDisabled }
isReadonly={ this.isReadonly }
isRequired={ this.isRequired }
name={ this.name }
onOdsBlur={ () => this.onBlur() }
onOdsChange={ (event: OdsSelectChangeEvent) => this.onOdsChange(event, true) }
onOdsClear={ (event: CustomEvent<void>) => event.stopPropagation() }
onOdsReset={ (event: CustomEvent<void>) => event.stopPropagation() }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { OdsInput } from '../../../input/src';
import { setInternalsValidityFromOdsComponent } from '../../../../utils/dom';
import { ODS_TIMEZONES_PRESET } from '../constant/timezone-preset';
import { type ODS_TIMEZONE, ODS_TIMEZONES, type OdsTimezone } from '../constant/timezones';

Expand Down Expand Up @@ -55,13 +57,17 @@ function parseTimezones(timezones?: OdsTimezone[] | ODS_TIMEZONES_PRESET | strin
return [];
}

function setFormValue(internals: ElementInternals, value: string | null): void {
async function updateInternals(internals: ElementInternals, value: string | null, inputEl?: HTMLElement & OdsInput): Promise<void> {
internals.setFormValue(value?.toString() ?? '');

if (inputEl) {
await setInternalsValidityFromOdsComponent(inputEl, internals);
}
}

export {
formatValue,
getCurrentTimezone,
parseTimezones,
setFormValue,
updateInternals,
};
2 changes: 1 addition & 1 deletion packages/ods/src/components/timepicker/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

<body>
<p>Default</p>
<ods-timepicker id="timepicker-default" timezones="all">
<ods-timepicker id="timepicker-default" timezones="all" is-required default-value="14:15">
</ods-timepicker>

<button id="clear-button">clear</button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,62 @@ describe('ods-timepicker rendering', () => {
expect(selectStyle.getPropertyValue('width')).toBe(width);
});
});

describe('error state', () => {
it('should render in error on form submit, before any changes, if invalid', async() => {
await setup('<form method="get" onsubmit="return false"><ods-timepicker is-required></ods-timepicker></form>');

await page.evaluate(() => {
document.querySelector<HTMLFormElement>('form')?.requestSubmit();
});
await page.waitForChanges();

const hasErrorClass = await page.evaluate(() => {
return document.querySelector('ods-timepicker')?.shadowRoot?.querySelector('ods-input')?.shadowRoot?.querySelector('input')?.classList.contains('ods-input__input--error');
});
expect(hasErrorClass).toBe(true);
});

it('should toggle the error state on value change', async() => {
await setup('<form method="get" onsubmit="return false"><ods-timepicker is-required></ods-timepicker></form>');

await el.type('11:11');
await page.waitForChanges();

const hasErrorClass = await page.evaluate(() => {
return document.querySelector('ods-timepicker')?.shadowRoot?.querySelector('ods-input')?.shadowRoot?.querySelector('input')?.classList.contains('ods-input__input--error');
});
expect(hasErrorClass).toBe(false);

await el.callMethod('clear');
await page.click('body', { offset: { x: 200, y: 200 } }); // Blur
await page.waitForChanges();

const hasErrorClass2 = await page.evaluate(() => {
return document.querySelector('ods-timepicker')?.shadowRoot?.querySelector('ods-input')?.shadowRoot?.querySelector('input')?.classList.contains('ods-input__input--error');
});
await page.waitForChanges();
expect(hasErrorClass2).toBe(true);
});

it('should enforce the error state if has-error is set even on valid timepicker', async() => {
await setup('<form method="get" onsubmit="return false"><ods-timepicker is-required has-error value="dummy"></ods-timepicker></form>');
await page.waitForChanges();

const hasErrorClass = await page.evaluate(() => {
return document.querySelector('ods-timepicker')?.shadowRoot?.querySelector('ods-input')?.shadowRoot?.querySelector('input')?.classList.contains('ods-input__input--error');
});
expect(hasErrorClass).toBe(true);

await page.evaluate(() => {
document.querySelector<HTMLFormElement>('form')?.requestSubmit();
});
await page.waitForChanges();

const hasErrorClass2 = await page.evaluate(() => {
return document.querySelector('ods-timepicker')?.shadowRoot?.querySelector('ods-input')?.shadowRoot?.querySelector('input')?.classList.contains('ods-input__input--error');
});
expect(hasErrorClass2).toBe(true);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ jest.mock('../../src/controller/ods-timepicker', () => ({
formatValue: jest.fn(),
getCurrentTimezone: jest.fn(),
parseTimezones: (): ODS_TIMEZONE[] => ODS_TIMEZONES as ODS_TIMEZONE[],
setFormValue: jest.fn(),
updateInternals: jest.fn(),
}));
import type { SpecPage } from '@stencil/core/testing';
import { newSpecPage } from '@stencil/core/testing';
Expand Down
Loading

0 comments on commit a7e73ea

Please sign in to comment.