Skip to content

Commit

Permalink
feat(phone-number): add form validity
Browse files Browse the repository at this point in the history
  • Loading branch information
aesteves60 authored and dpellier committed Nov 28, 2024
1 parent fc482a7 commit 551d5dc
Show file tree
Hide file tree
Showing 10 changed files with 594 additions and 70 deletions.
Original file line number Diff line number Diff line change
@@ -1,14 +1,29 @@
import { AttachInternals, Component, Event, type EventEmitter, type FunctionalComponent, Host, Method, Prop, Watch, h } from '@stencil/core';
import { AttachInternals, Component, Event, type EventEmitter, type FunctionalComponent, Host, Listen, Method, Prop, State, Watch, h } from '@stencil/core';
import { PhoneNumberUtil } from 'google-libphonenumber';
import { submitFormOnEnter } from '../../../../../utils/dom';
import { type OdsInput, type OdsInputChangeEvent } from '../../../../input/src';
import { type OdsSelectChangeEvent, type OdsSelectCustomRendererData } from '../../../../select/src';
import { type OdsPhoneNumberCountryIsoCode } from '../../constants/phone-number-country-iso-code';
import { type OdsPhoneNumberCountryPreset } from '../../constants/phone-number-country-preset';
import { type OdsPhoneNumberLocale } from '../../constants/phone-number-locale';
import { type TranslatedCountryMap, formatPhoneNumber, getCurrentIsoCode, getCurrentLocale, getNationalPhoneNumberExample, getTranslatedCountryMap, getValidityState, isValidPhoneNumber, parseCountries, setFormValue, sortCountriesByName } from '../../controller/ods-phone-number';
import {
type TranslatedCountryMap,
formatPhoneNumber,
getCurrentIsoCode,
getCurrentLocale,
getNationalPhoneNumberExample,
getTranslatedCountryMap,
getValidityMessage,
getValidityState,
isValidPhoneNumber,
parseCountries,
sortCountriesByName,
updateInternals,
} from '../../controller/ods-phone-number';
import { type OdsPhoneNumberChangeEventDetail } from '../../interfaces/events';

const VALUE_DEFAULT_VALUE = null;

@Component({
formAssociated: true,
shadow: {
Expand All @@ -20,13 +35,16 @@ import { type OdsPhoneNumberChangeEventDetail } from '../../interfaces/events';
export class OdsPhoneNumber {
private hasCountries: boolean = false;
private i18nCountriesMap?: TranslatedCountryMap;
private inputElement?: OdsInput;
private inputElement?: HTMLElement & OdsInput;
private isInitialLoadDone: boolean = false;
private parsedCountryCodes: OdsPhoneNumberCountryIsoCode[] = [];
private phoneUtils = PhoneNumberUtil.getInstance();
private shouldUpdateIsInvalidState: boolean = false;

@AttachInternals() private internals!: ElementInternals;

@State() isInvalid: boolean = false;

@Prop({ reflect: true }) public ariaLabel: HTMLElement['ariaLabel'] = null;
@Prop({ reflect: true }) public ariaLabelledby?: string;
@Prop({ reflect: true }) public countries?: OdsPhoneNumberCountryIsoCode[] | OdsPhoneNumberCountryPreset | string;
Expand All @@ -41,28 +59,58 @@ export class OdsPhoneNumber {
@Prop({ mutable: true, reflect: true }) public locale?: OdsPhoneNumberLocale;
@Prop({ reflect: true }) public name!: string;
@Prop({ reflect: true }) public pattern?: string;
@Prop({ mutable: true, reflect: true }) public value: string | null = null;
@Prop({ mutable: true, reflect: true }) public value: string | null = VALUE_DEFAULT_VALUE;

@Event() odsBlur!: EventEmitter<void>;
@Event() odsChange!: EventEmitter<OdsPhoneNumberChangeEventDetail>;
@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> {
return this.internals.checkValidity();
}

@Method()
async clear(): Promise<void> {
await this.inputElement?.clear();
this.shouldUpdateIsInvalidState = true;
}

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

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

@Method()
public async getValidity(): Promise<ValidityState | undefined> {
const inputValidity = await this.inputElement?.getValidity();
return getValidityState(this.hasError, inputValidity);
async reportValidity(): Promise<boolean> {
return this.internals.reportValidity();
}

@Method()
public async reset(): Promise<void> {
return this.inputElement?.reset();
async reset(): Promise<void> {
await this.inputElement?.reset();
this.shouldUpdateIsInvalidState = true;
}

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

@Watch('countries')
Expand All @@ -75,7 +123,7 @@ export class OdsPhoneNumber {
onIsoCodeChange(): void {
if (this.isInitialLoadDone) {
this.value = '';
this.hasError = false;
this.shouldUpdateIsInvalidState = true;
}
}

Expand All @@ -96,7 +144,7 @@ export class OdsPhoneNumber {
this.locale = currentLocale;
}

if (this.value) {
if (!this.value && (this.value !== VALUE_DEFAULT_VALUE || this.defaultValue)) {
this.onInputChange(new CustomEvent('', {
detail: {
name: this.name,
Expand All @@ -109,28 +157,42 @@ export class OdsPhoneNumber {
this.isInitialLoadDone = true;
}

async componentDidLoad(): Promise<void> {
const formattedValue = formatPhoneNumber(this.value, this.isoCode, this.phoneUtils);
const inputValidity = await this.inputElement?.getValidity?.();
const validityState = getValidityState(this.isInvalid, inputValidity);
await updateInternals(this.internals, formattedValue, validityState, this.inputElement);
}

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

private getIsInvalid(): boolean {
return !isValidPhoneNumber(this.value, this.isoCode, this.phoneUtils) || !this.internals.validity.valid;
}

private getPlaceholder(): string {
return getNationalPhoneNumberExample(this.isoCode, this.phoneUtils);
}

private onInputChange(event: OdsInputChangeEvent): void {
private onBlur(): void {
this.isInvalid = this.getIsInvalid();
}

private async onInputChange(event: OdsInputChangeEvent): Promise<void> {
event.stopImmediatePropagation();

this.value = event.detail.value?.toString() ?? null;
this.hasError = !isValidPhoneNumber(this.value, this.isoCode, this.phoneUtils);
const formattedValue = formatPhoneNumber(this.value, this.isoCode, this.phoneUtils);

const formattedValue = formatPhoneNumber(this.value, this.hasError, this.isoCode, this.phoneUtils);
setFormValue(this.internals, formattedValue);
const validityState = await this.updateValidity(formattedValue);

this.odsChange.emit({
isoCode: this.isoCode,
name: this.name,
previousValue: event.detail.previousValue?.toString() ?? null,
validity: getValidityState(this.hasError, event.detail.validity),
validity: validityState,
value: formattedValue,
});
}
Expand All @@ -139,9 +201,26 @@ export class OdsPhoneNumber {
this.isoCode = event.detail.value as OdsPhoneNumberCountryIsoCode;
}

private async updateValidity(formattedValue: string | null): Promise<ValidityState> {
const inputValidityState = await this.inputElement?.getValidity();
const isNotValidPhoneNumber = !isValidPhoneNumber(this.value, this.isoCode, this.phoneUtils);
const validityMessage = getValidityMessage(isNotValidPhoneNumber);
const validityState = getValidityState(isNotValidPhoneNumber, inputValidityState);

await updateInternals(this.internals, formattedValue, validityState, this.inputElement, validityMessage);
// update here after update internals
if (this.shouldUpdateIsInvalidState) {
this.isInvalid = this.getIsInvalid();
this.shouldUpdateIsInvalidState = false;
}
return validityState;
}

render(): FunctionalComponent {
return (
<Host class="ods-phone-number">
<Host class="ods-phone-number"
disabled={ this.isDisabled }
readonly={ this.isReadonly }>
{
this.hasCountries &&
<ods-select
Expand All @@ -160,7 +239,7 @@ export class OdsPhoneNumber {
`,
}}
dropdownWidth="auto"
hasError={ this.hasError }
hasError={ this.hasError || this.isInvalid }
isDisabled={ this.isDisabled }
isReadonly={ this.isReadonly }
name="iso-code"
Expand Down Expand Up @@ -190,19 +269,20 @@ export class OdsPhoneNumber {
'ods-phone-number__input--sibling': this.hasCountries,
}}
defaultValue={ this.defaultValue }
hasError={ this.hasError }
hasError={ this.hasError || this.isInvalid }
isClearable={ this.isClearable }
isDisabled={ this.isDisabled }
isLoading={ this.isLoading }
isReadonly={ this.isReadonly }
isRequired={ this.isRequired }
name={ this.name }
onKeyUp={ (event: KeyboardEvent): void => submitFormOnEnter(event, this.internals.form) }
onOdsBlur={ () => this.onBlur() }
onOdsChange={ (e: OdsInputChangeEvent) => this.onInputChange(e) }
exportparts="input"
pattern={ this.pattern }
placeholder={ this.getPlaceholder() }
ref={ (el?: unknown): OdsInput => this.inputElement = el as OdsInput }
ref={ (el?: unknown): OdsInput => this.inputElement = el as HTMLElement & OdsInput }
value={ this.value }>
</ods-input>
</Host>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { OdsInput } from '../../../input/src';
import { type PhoneNumber, PhoneNumberFormat, type PhoneNumberUtil } from 'google-libphonenumber';
import { setInternalsValidityFromValidityState } from '../../../../utils/dom';
import { ODS_PHONE_NUMBER_COUNTRY_ISO_CODE, ODS_PHONE_NUMBER_COUNTRY_ISO_CODES, type OdsPhoneNumberCountryIsoCode } from '../constants/phone-number-country-iso-code';
import { ODS_PHONE_NUMBER_COUNTRY_PRESET, type OdsPhoneNumberCountryPreset } from '../constants/phone-number-country-preset';
import { ODS_PHONE_NUMBER_LOCALE, ODS_PHONE_NUMBER_LOCALES, type OdsPhoneNumberLocale } from '../constants/phone-number-locale';
Expand All @@ -13,11 +15,7 @@ import countriesTranslationPt from '../i18n/countries-pt';

type TranslatedCountryMap = Map<OdsPhoneNumberCountryIsoCode, { isoCode: OdsPhoneNumberCountryIsoCode , name: string, phoneCode?: number }>;

function formatPhoneNumber(value: string | null, hasError: boolean, isoCode: OdsPhoneNumberCountryIsoCode | undefined, phoneUtils: PhoneNumberUtil): string | null {
if (hasError) {
return null;
}

function formatPhoneNumber(value: string | null, isoCode: OdsPhoneNumberCountryIsoCode | undefined, phoneUtils: PhoneNumberUtil): string | null {
const phoneNumber = parsePhoneNumber(value, isoCode, phoneUtils);

if (!phoneNumber) {
Expand Down Expand Up @@ -93,29 +91,32 @@ function getTranslatedCountryMap(locale: OdsPhoneNumberLocale, phoneUtils: Phone
}, new Map());
}

function getValidityState(hasError: boolean, inputValidity?: ValidityState): ValidityState {
function getValidityState(hasError: boolean, validityState?: ValidityState): ValidityState {
return {
badInput: inputValidity?.badInput || false,
customError: inputValidity?.customError || false,
patternMismatch: inputValidity?.patternMismatch || false,
rangeOverflow: inputValidity?.rangeOverflow || false,
rangeUnderflow: inputValidity?.rangeUnderflow || false,
stepMismatch: inputValidity?.stepMismatch || false,
tooLong: inputValidity?.tooLong || false,
tooShort: inputValidity?.tooShort || false,
typeMismatch: inputValidity?.typeMismatch || false,
valid: inputValidity?.valid === false ? inputValidity?.valid : !hasError,
valueMissing: inputValidity?.valueMissing || false,
badInput: validityState?.badInput || hasError,
customError: validityState?.customError || false,
patternMismatch: validityState?.patternMismatch || false,
rangeOverflow: validityState?.rangeOverflow || false,
rangeUnderflow: validityState?.rangeUnderflow || false,
stepMismatch: validityState?.stepMismatch || false,
tooLong: validityState?.tooLong || false,
tooShort: validityState?.tooShort || false,
typeMismatch: validityState?.typeMismatch || false,
valid: validityState?.valid === false ? validityState?.valid : !hasError,
valueMissing: validityState?.valueMissing || false,
};
}

function getValidityMessage(hasError: boolean): string {
return hasError && 'Wrong phone number format' || '';
}

function isValidPhoneNumber(phoneNumber: string | null, isoCode: OdsPhoneNumberCountryIsoCode | undefined, phoneUtils: PhoneNumberUtil): boolean {
if (!phoneNumber || !isoCode) {
return true;
}

const number = parsePhoneNumber(phoneNumber, isoCode, phoneUtils);

if (!number) {
return false;
}
Expand Down Expand Up @@ -155,8 +156,13 @@ function parsePhoneNumber(phoneNumber: string | null, isoCode: OdsPhoneNumberCou
}
}

function setFormValue(internals: ElementInternals, value: string | null): void {
// eslint-disable-next-line max-params
async function updateInternals(internals: ElementInternals, value: string | null, validityState: ValidityState, inputEl?: HTMLElement & OdsInput, validityMessage?: string): Promise<void> {
internals.setFormValue(value?.toString() ?? '');

if (inputEl) {
await setInternalsValidityFromValidityState(inputEl, internals, validityState, validityMessage);
}
}

function sortCountriesByName(countryCodes: OdsPhoneNumberCountryIsoCode[], countriesMap: TranslatedCountryMap): OdsPhoneNumberCountryIsoCode[] {
Expand All @@ -181,10 +187,11 @@ export {
getCurrentLocale,
getNationalPhoneNumberExample,
getTranslatedCountryMap,
getValidityMessage,
getValidityState,
isValidPhoneNumber,
parseCountries,
parsePhoneNumber,
setFormValue,
updateInternals,
sortCountriesByName,
};
21 changes: 11 additions & 10 deletions packages/ods/src/components/phone-number/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
</head>

<body style="margin-bottom: 200px;">
<h1>Input only</h1>
<!-- <h1>Input only</h1>
<p>Default</p>
<ods-phone-number>
</ods-phone-number>
Expand Down Expand Up @@ -58,11 +58,12 @@ <h1>Input only</h1>
<p>IsoCode + wrong value</p>
<ods-phone-number iso-code="fr" value="1234">
</ods-phone-number>
</ods-phone-number> -->

<p>Methods & Events</p>
<!-- <p>Methods & Events</p>
<ods-phone-number id="opn-input-methods"
default-value="+18444629181">
default-value="+18444629181"
is-required>
</ods-phone-number>
<button id="opn-input-clear-button">Clear</button>
<button id="opn-input-reset-button">Reset</button>
Expand All @@ -84,13 +85,13 @@ <h1>Input only</h1>
// opnInputMethods.addEventListener('odsClear', () => { console.log('on clear') });
// opnInputMethods.addEventListener('odsFocus', () => { console.log('on focus') });
// opnInputMethods.addEventListener('odsReset', () => { console.log('on reset') });
</script>
</script> -->

<p>Form</p>
<form id="opn-input-form">
<ods-phone-number default-value="0123456789"
name="input-form"
value="0666666666">
<ods-phone-number name="input-form"
value="+18444629181"
is-required>
</ods-phone-number>
<button id="opn-input-form-reset-button" type="reset">Reset</button>
<button id="opn-input-form-submit-button" type="submit">Submit</button>
Expand All @@ -105,7 +106,7 @@ <h1>Input only</h1>
});
</script>

<p>Countries update</p>
<!--<p>Countries update</p>
<ods-phone-number id="phone-number-countries">
</ods-phone-number>
<button id="btn-update-countries">Update countries</button>
Expand Down Expand Up @@ -284,6 +285,6 @@ <h1>Input and countries</h1>
<label for="labelled-phone-number-countries">My phone-number with countries</label>
<ods-phone-number id="labelled-phone-number-countries"
countries="all">
</ods-phone-number>
</ods-phone-number> -->
</body>
</html>
Loading

0 comments on commit 551d5dc

Please sign in to comment.