Skip to content

Commit

Permalink
feat(phone-number): implement component
Browse files Browse the repository at this point in the history
  • Loading branch information
dpellier committed Jul 29, 2024
1 parent 99b16bf commit d248a9b
Show file tree
Hide file tree
Showing 50 changed files with 3,548 additions and 18 deletions.
2 changes: 2 additions & 0 deletions packages/ods/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"dependencies": {
"@floating-ui/dom": "1.6.3",
"@stencil/core": "4.12.2",
"google-libphonenumber": "3.2.35",
"tom-select": "2.3.1"
},
"devDependencies": {
Expand All @@ -35,6 +36,7 @@
"@stencil/react-output-target": "0.5.3",
"@stencil/sass": "3.0.9",
"@stencil/vue-output-target": "0.8.8",
"@types/google-libphonenumber": "7.4.30",
"@types/jest": "29.5.12",
"@types/react": "18.2.56",
"@types/react-dom": "18.2.19",
Expand Down
1 change: 1 addition & 0 deletions packages/ods/react/tests/_app/src/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const componentNames = [
'tabs',
'switch',
'accordion',
'phone-number',
//--generator-anchor--
];

Expand Down
21 changes: 21 additions & 0 deletions packages/ods/react/tests/_app/src/components/ods-phone-number.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React from 'react-dom/client';
import { OdsPhoneNumber } from 'ods-components-react';

const PhoneNumber = () => {
function onOdsChange() {
console.log('React phone number odsChange');
}

return (
<>
<OdsPhoneNumber name="ods-phone-number"
onOdsChange={ onOdsChange } />

<OdsPhoneNumber isDisabled
name="ods-phone-number-disabled"
onOdsChange={ onOdsChange } />
</>
);
};

export default PhoneNumber;
51 changes: 51 additions & 0 deletions packages/ods/react/tests/e2e/ods-phone-number.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { Page } from 'puppeteer';
import { goToComponentPage, setupBrowser } from '../setup';

describe('ods-phone-number react', () => {
const setup = setupBrowser();
let page: Page;

beforeAll(async () => {
page = setup().page;
});

beforeEach(async () => {
await goToComponentPage(page, 'ods-phone-number');
});

it('render the component correctly', async () => {
const elem = await page.$('ods-phone-number');
const boundingBox = await elem?.boundingBox();

expect(boundingBox?.height).toBeGreaterThan(0);
expect(boundingBox?.width).toBeGreaterThan(0);
});

it('trigger the odsChange handler on type', async () => {
const elem = await page.$('ods-phone-number:not([is-disabled]) >>> input');
let consoleLog = '';
page.on('console', (consoleObj) => {
consoleLog = consoleObj.text();
});

await elem?.type('a');
// Small delay to ensure page console event has been resolved
await new Promise((resolve) => setTimeout(resolve, 100));

expect(consoleLog).toBe('React phone number odsChange');
});

it('does not trigger the odsChange handler on type if disabled', async () => {
const elem = await page.$('ods-phone-number[is-disabled] >>> input');
let consoleLog = '';
page.on('console', (consoleObj) => {
consoleLog = consoleObj.text();
});

await elem?.type('a');
// Small delay to ensure page console event has been resolved
await new Promise((resolve) => setTimeout(resolve, 100));

expect(consoleLog).toBe('');
});
});
1 change: 1 addition & 0 deletions packages/ods/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ export * from './file-upload/src';
export * from './tabs/src';
export * from './switch/src';
export * from './accordion/src';
export * from './phone-number/src';
5 changes: 5 additions & 0 deletions packages/ods/src/components/phone-number/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Local Stencil command generates external ods component build at the root of the project
# Excluding them is a temporary solution to avoid pushing generated files
# But the issue may cause main build (ods-component package) to fails, as it detects multiples occurences
# of the same component and thus you have to delete all those generated dir manually
*/src/
18 changes: 18 additions & 0 deletions packages/ods/src/components/phone-number/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# How-to generate flag spritesheet

For now, we use https://github.com/flarik/css-sprite-flags.

You will need to add those last 7 that are missing in the repo css:

```scss
'wf': -0px -360px,
'ws': -24px -360px,
'ye': -48px -360px,
'yt': -72px -360px,
'za': -96px -360px,
'zm': -120px -360px,
'zw': -144px -360px,
```

If we need to update the flag list to a custom one, please describe precisely
here the workflow used to generate such files.
19 changes: 19 additions & 0 deletions packages/ods/src/components/phone-number/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "@ovhcloud/ods-component-phone-number",
"version": "17.1.0",
"private": true,
"description": "ODS PhoneNumber component",
"main": "dist/index.cjs.js",
"collection": "dist/collection/collection-manifest.json",
"scripts": {
"clean": "rimraf .stencil coverage dist docs-api www",
"doc": "typedoc --pretty --plugin ../../../scripts/typedoc-plugin-decorator.js && node ../../../scripts/generate-typedoc-md.js",
"lint:scss": "stylelint 'src/components/**/*.scss'",
"lint:ts": "eslint '{src,tests}/**/*.{js,ts,tsx}'",
"start": "stencil build --dev --watch --serve",
"test:e2e": "stencil test --e2e --config stencil.config.ts",
"test:e2e:ci": "tsc --noEmit && stencil test --e2e --ci --runInBand --config stencil.config.ts",
"test:spec": "stencil test --spec --config stencil.config.ts --coverage",
"test:spec:ci": "tsc --noEmit && stencil test --config stencil.config.ts --spec --ci --coverage"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
@import '../../assets/sprite-flags-24x24';

:host(.ods-phone-number) {
display: inline-flex;
}

.ods-phone-number {
&__iso-codes {
min-width: 64px;

&::part(select) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}

&::part(flag) {
display: inline-block;
background-image: $sprite-flags-background-image;
background-repeat: no-repeat;
width: 24px;
height: 24px;
}

@each $iso-code, $background-position in $sprite-flags-background-position {
&::part(flag-#{$iso-code}) {
background-position: $background-position;
}
}
}

&__input {
&--sibling {
margin-left: -1px; // to merge input and select borders

&::part(input) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { AttachInternals, Component, Event, type EventEmitter, type FunctionalComponent, Host, Method, Prop, Watch, h } from '@stencil/core';
import { PhoneNumberUtil } from 'google-libphonenumber';
import { type OdsInput, type OdsInputValueChangeEvent } from '../../../../input/src';
import { type OdsSelectCustomRendererData, type OdsSelectEventChange } 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 OdsPhoneNumberValueChangeEventDetail } from '../../interfaces/events';

@Component({
formAssociated: true,
shadow: true,
styleUrl: 'ods-phone-number.scss',
tag: 'ods-phone-number',
})
export class OdsPhoneNumber {
private hasCountries: boolean = false;
private i18nCountriesMap?: TranslatedCountryMap;
private inputElement?: OdsInput;
private parsedCountryCodes: OdsPhoneNumberCountryIsoCode[] = [];
private phoneUtils = PhoneNumberUtil.getInstance();

@AttachInternals() private internals!: ElementInternals;

@Prop({ reflect: true }) public ariaLabel: HTMLElement['ariaLabel'] = null;
@Prop({ reflect: true }) public ariaLabelledby?: string;
@Prop({ reflect: true }) public countries?: OdsPhoneNumberCountryIsoCode[] | OdsPhoneNumberCountryPreset | string;
@Prop({ reflect: true }) public defaultValue?: string;
@Prop({ mutable: true, reflect: true }) public hasError: boolean = false;
@Prop({ reflect: true }) public isClearable: boolean = false;
@Prop({ reflect: true }) public isDisabled: boolean = false;
@Prop({ reflect: true }) public isLoading: boolean = false;
@Prop({ reflect: true }) public isReadonly: boolean = false;
@Prop({ reflect: true }) public isRequired: boolean = false;
@Prop({ mutable: true, reflect: true }) public isoCode?: OdsPhoneNumberCountryIsoCode;
@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;

@Event() odsBlur!: EventEmitter<void>;
@Event() odsChange!: EventEmitter<OdsPhoneNumberValueChangeEventDetail>;
@Event() odsClear!: EventEmitter<void>;
@Event() odsFocus!: EventEmitter<void>;
@Event() odsReset!: EventEmitter<void>;

@Method()
async clear(): Promise<void> {
return this.inputElement?.clear();
}

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

@Method()
async reset(): Promise<void> {
return this.inputElement?.reset();
}

@Watch('countries')
onCountriesChange(): void {
this.parsedCountryCodes = parseCountries(this.countries, this.phoneUtils) || [];
this.hasCountries = !!this.parsedCountryCodes?.length;
}

@Watch('isoCode')
onIsoCodeChange(): void {
this.value = '';
this.hasError = false;
}

@Watch('locale')
onLocaleChange(locale: OdsPhoneNumberLocale): void {
this.i18nCountriesMap = getTranslatedCountryMap(locale, this.phoneUtils);
this.parsedCountryCodes = sortCountriesByName(this.parsedCountryCodes, this.i18nCountriesMap);
}

componentWillLoad(): void {
this.onCountriesChange();
this.isoCode = getCurrentIsoCode(this.isoCode, this.parsedCountryCodes[0]);
this.locale = getCurrentLocale(this.locale);
this.onLocaleChange(this.locale);

if (this.value) {
this.onInputChange(new CustomEvent('', {
detail: {
name: this.name,
value: this.value,
},
}));
}
}

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

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

private onInputChange(event: OdsInputValueChangeEvent): 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.hasError, this.isoCode, this.phoneUtils);
setFormValue(this.internals, formattedValue);

this.odsChange.emit({
isoCode: this.isoCode,
name: this.name,
validity: getValidityState(this.hasError, event.detail.validity),
value: formattedValue,
});
}

private onSelectChange(event: OdsSelectEventChange): void {
this.isoCode = event.detail.value as OdsPhoneNumberCountryIsoCode;
}

render(): FunctionalComponent {
return (
<Host class="ods-phone-number">
{
this.hasCountries &&
<ods-select
borderRounded="left"
class="ods-phone-number__iso-codes"
customRenderer={{
item: (data: OdsSelectCustomRendererData) => `<span part="flag flag-${data.value}"></span>`,
option: (data: OdsSelectCustomRendererData) => `
<div style="display: grid; grid-template-columns: max-content 1fr; column-gap: 8px; white-space: nowrap;">
<span part="flag flag-${data.value}"></span>
<span>${data.name} (+${data.phoneCode})</span>
</div>
`,
}}
dropdownWidth="auto"
hasError={ this.hasError }
isDisabled={ this.isDisabled }
isReadonly={ this.isReadonly }
name="iso-code"
onOdsChange={ (e: OdsSelectEventChange) => this.onSelectChange(e) }
part="select"
value={ this.isoCode }>
{
this.parsedCountryCodes.map((isoCode) => {
const i18nCountry = this.i18nCountriesMap?.get(isoCode);
return (
<option
data-name={ i18nCountry?.name }
data-phone-code={ i18nCountry?.phoneCode }
value={ isoCode }>
</option>
);
})
}
</ods-select>
}

<ods-input
ariaLabel={ this.ariaLabel }
ariaLabelledby={ this.ariaLabelledby }
class={{
'ods-phone-number__input': true,
'ods-phone-number__input--sibling': this.hasCountries,
}}
defaultValue={ this.defaultValue }
hasError={ this.hasError }
isClearable={ this.isClearable }
isDisabled={ this.isDisabled }
isLoading={ this.isLoading }
isReadonly={ this.isReadonly }
isRequired={ this.isRequired }
name={ this.name }
onOdsChange={ (e: OdsInputValueChangeEvent) => this.onInputChange(e) }
exportparts="input"
pattern={ this.pattern }
placeholder={ this.getPlaceholder() }
ref={ (el?: unknown): OdsInput => this.inputElement = el as OdsInput }
value={ this.value }>
</ods-input>
</Host>
);
}
}
Loading

0 comments on commit d248a9b

Please sign in to comment.