Skip to content

Commit

Permalink
add phone input component
Browse files Browse the repository at this point in the history
  • Loading branch information
tintinthong committed Dec 2, 2024
1 parent 6ca40c2 commit a77fef4
Show file tree
Hide file tree
Showing 7 changed files with 201 additions and 0 deletions.
2 changes: 2 additions & 0 deletions packages/boxel-ui/addon/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"@floating-ui/dom": "^1.6.3",
"@glint/template": "1.3.0",
"classnames": "^2.3.2",
"countries-list": "^3.1.1",
"dayjs": "^1.11.7",
"ember-basic-dropdown": "^8.0.0",
"ember-css-url": "^1.0.0",
Expand All @@ -59,6 +60,7 @@
"ember-velcro": "^2.1.3",
"file-loader": "^6.2.0",
"focus-trap": "^7.4.3",
"libphonenumber-js": "^1.11.15",
"lodash": "^4.17.21",
"pluralize": "^8.0.0",
"tracked-built-ins": "^3.2.0",
Expand Down
2 changes: 2 additions & 0 deletions packages/boxel-ui/addon/src/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import BoxelMultiSelect, {
import Pill from './components/pill/index.gts';
import ProgressBar from './components/progress-bar/index.gts';
import ProgressRadial from './components/progress-radial/index.gts';
import PhoneInput from './components/phone-input/index.gts';
import RadioInput from './components/radio-input/index.gts';
import RealmIcon from './components/realm-icon/index.gts';
import ResizablePanelGroup, {
Expand Down Expand Up @@ -95,6 +96,7 @@ export {
Pill,
ProgressBar,
ProgressRadial,
PhoneInput,
RadioInput,
RealmIcon,
ResizablePanel,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export const Select: TemplateOnlyComponent<SelectAccessorySignature> =
@onChange={{@onChange}}
@onBlur={{@onBlur}}
@matchTriggerWidth={{@matchTriggerWidth}}
@selectedItemComponent={{@selectedItemComponent}}
data-test-boxel-input-group-select-accessory-trigger
...attributes
as |item|
Expand Down
152 changes: 152 additions & 0 deletions packages/boxel-ui/addon/src/components/phone-input/index.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import type { TemplateOnlyComponent } from '@ember/component/template-only';
import { action } from '@ember/object';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { type TCountryCode, countries, getEmojiFlag } from 'countries-list';
import {
type CountryCallingCode,
type CountryCode,
getCountries,
getCountryCallingCode,
getExampleNumber,
isValidPhoneNumber,
} from 'libphonenumber-js';
// @ts-expect-error import not found
import examples from 'libphonenumber-js/mobile/examples';
import { debounce } from 'lodash';

import { type InputValidationState } from '../input/index.gts';
import BoxelInputGroup from '../input-group/index.gts';

interface Signature {
Args: {
value: string;
onInput: (value: string) => void;
};
Blocks: {
default: [];
};
Element: HTMLElement;
}

interface CountryInfo {
callingCode?: CountryCallingCode;
code: CountryCode;
name?: string;
flag?: string;
example?: {
callingCode: CountryCallingCode;
nationalNumber: string;
};
}

const getCountryInfo = (countryCode: CountryCode): CountryInfo | undefined => {
let example = getExampleNumber(countryCode, examples);
let callingCode = getCountryCallingCode(countryCode);
let c = countries[countryCode as TCountryCode];
if (c === undefined) {
return undefined;
}
return {
code: countryCode,
callingCode,
name: c ? c.name : undefined,
flag: getEmojiFlag(countryCode as TCountryCode),
example: example
? {
callingCode,
nationalNumber: example.format('NATIONAL'),
}
: undefined,
};
};

export default class PhoneInput extends Component<Signature> {
@tracked items: Array<CountryInfo> = [];
@tracked selectedItem: CountryInfo = getCountryInfo('US')!;
@tracked validationState: InputValidationState = 'initial';
@tracked input: string = this.args.value ?? '';

@action onSelectItem(item: CountryInfo): void {
this.selectedItem = item;
if (this.input.length > 0) {
this.validationState = isValidPhoneNumber(
this.input,
this.selectedItem.code,
)
? 'valid'
: 'invalid';
}
}

constructor(owner: unknown, args: any) {
super(owner, args);
this.items = getCountries()
.map((code) => {
return getCountryInfo(code);
})
.filter((c) => c !== undefined) as CountryInfo[];
}

get placeholder(): string | undefined {
if (this.selectedItem) {
return this.selectedItem.example?.nationalNumber;
}
return undefined;
}

get phoneNumber(): string {
return `+${this.selectedItem.callingCode} `;
}

@action onInput(v: string): void {
this.debouncedInput(v);
}

private debouncedInput = debounce((input: string) => {
this.validationState = isValidPhoneNumber(input, this.selectedItem.code)
? 'valid'
: 'invalid';
this.input = input;
//save when the state is valid
if (this.validationState === 'valid') {
this.args.onInput(this.input);
}
}, 300);

<template>
<BoxelInputGroup
@placeholder={{this.placeholder}}
@state={{this.validationState}}
@onInput={{this.onInput}}
@value={{this.input}}
>
<:before as |Accessories|>
<Accessories.Select
@placeholder={{this.placeholder}}
@selected={{this.selectedItem}}
@onChange={{this.onSelectItem}}
@options={{this.items}}
@selectedItemComponent={{PhoneSelectedItem}}
@searchEnabled={{true}}
@searchField='name'
@matchTriggerWidth={{false}}
aria-label='Select an country calling code'
as |item|
>
<div>{{item.flag}} {{item.name}} +{{item.callingCode}}</div>
</Accessories.Select>
</:before>
</BoxelInputGroup>
</template>
}

export interface SelectedItemSignature {
Args: {
option: any;
};
Element: HTMLElement;
}

const PhoneSelectedItem: TemplateOnlyComponent<SelectedItemSignature> =
<template><div>{{@option.flag}} +{{@option.callingCode}}</div></template>;
28 changes: 28 additions & 0 deletions packages/boxel-ui/addon/src/components/phone-input/usage.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Component from '@glimmer/component';
import FreestyleUsage from 'ember-freestyle/components/freestyle/usage';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import PhoneInput from './index.gts';

export default class PhoneInputUsage extends Component {
@tracked value = '';

@action onInput(value: string): void {
this.value = value;
}

<template>
<FreestyleUsage @name='PhoneInput'>
<:description>
<p>
PhoneInput is a component that allows users to input phone numbers
with a dropdown select of country code and validation of the inputted
numbers
</p>
</:description>
<:example>
<PhoneInput @value={{this.value}} @onInput={{this.onInput}} />
</:example>
</FreestyleUsage>
</template>
}
2 changes: 2 additions & 0 deletions packages/boxel-ui/addon/src/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import CardContainerUsage from './components/card-container/usage.gts';
import CardContentContainerUsage from './components/card-content-container/usage.gts';
import CardHeaderUsage from './components/card-header/usage.gts';
import CircleSpinnerUsage from './components/circle-spinner/usage.gts';
import PhoneInputUsage from './components/phone-input/usage.gts';
import DateRangePickerUsage from './components/date-range-picker/usage.gts';
import DragAndDropUsage from './components/drag-and-drop/usage.gts';
import DropdownTriggerUsage from './components/dropdown/trigger/usage.gts';
Expand Down Expand Up @@ -48,6 +49,7 @@ export const ALL_USAGE_COMPONENTS = [
['CardContentContainer', CardContentContainerUsage],
['CardHeader', CardHeaderUsage],
['CircleSpinner', CircleSpinnerUsage],
['PhoneInput', PhoneInputUsage],
['DateRangePicker', DateRangePickerUsage],
['DragAndDrop', DragAndDropUsage],
['DropdownTrigger', DropdownTriggerUsage],
Expand Down
14 changes: 14 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit a77fef4

Please sign in to comment.