Skip to content

Commit

Permalink
feat: Add phone field input (#72)
Browse files Browse the repository at this point in the history
* feat: Add phone field input

* chore: Sort package.json components

* feat: Automatically adjust cursor position after formatting

* feat: Handle deletion of special characters in display

* chore: PR Suggestions

Co-authored-by: Tyler Senter <tyler.senter.427@gmail.com>

* feat: Extract phone formats

* fix: TS errors

* chore: Add keyboard tests for phone input

---------

Co-authored-by: Tyler Senter <tyler.senter.427@gmail.com>
  • Loading branch information
KeithClinard and TSenter authored Aug 21, 2024
1 parent 98e2075 commit 03091e0
Show file tree
Hide file tree
Showing 8 changed files with 458 additions and 1 deletion.
109 changes: 109 additions & 0 deletions apps/ember-test-app/app/components/f/phone-field.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { fn } from '@ember/helper';
import { action, set } from '@ember/object';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import PhoneField from '@nrg-ui/ember/components/form/phone-field';
import bind from '@nrg-ui/ember/helpers/bind';
import FreestyleUsage from 'ember-freestyle/components/freestyle/usage';
import FreestyleSection from 'ember-freestyle/components/freestyle-section';

import CodeBlock from '../code-block';

// TypeScript doesn't recognize that this function is used in the template
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function log(...msg: string[]) {
console.log(msg.join(' '));
}

class Model {
@tracked
property = '';
}

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

model = new Model();

@tracked
basic = false;

@tracked
disabled = false;

@tracked
readonly = false;

@tracked
value = '';

@action
update(key: string, value: unknown) {
set(this, key, value);
}

<template>
<FreestyleSection @name="Phone Field" as |Section|>
<Section.subsection @name="Basic">
<FreestyleUsage>
<:example>
<PhoneField
class={{this.class}}
@basic={{this.basic}}
@binding={{bind this.model "property"}}
@disabled={{this.disabled}}
@readonly={{this.readonly}}
@onChange={{fn log "The value changed to"}}
/>
</:example>
<:api as |Args|>
<Args.String
@name="class"
@description="The class to apply to the button. Note that this is not an argument but rather a class applied directly to the button"
@value={{this.class}}
@onInput={{fn this.update "class"}}
@options={{this.classOptions}}
/>
<Args.Bool
@name="basic"
@defaultValue={{false}}
@description="When true, the border will be removed"
@value={{this.basic}}
@onInput={{fn this.update "basic"}}
/>
<Args.String
@name="binding"
@description="Create a two-way binding with the value"
@value={{this.model.property}}
@onInput={{fn this.update "model.property"}}
/>
<Args.Bool
@name="disabled"
@defaultValue={{false}}
@description="When true, the input will be disabled"
@value={{this.disabled}}
@onInput={{fn this.update "disabled"}}
/>
<Args.Bool
@name="readonly"
@defaultValue={{false}}
@description="When true, the input will be readonly"
@value={{this.readonly}}
@onInput={{fn this.update "readonly"}}
/>
<Args.Action
@name="onChange"
@description="The action to call when the value changes"
>
<CodeBlock
@lang="typescript"
@code="(newValue: string) => unknown"
/>
</Args.Action>
</:api>
</FreestyleUsage>
</Section.subsection>
</FreestyleSection>
</template>
}
3 changes: 2 additions & 1 deletion apps/ember-test-app/app/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ Router.map(function () {
this.route('header');
this.route('icon');
this.route('navbar');
this.route('phone-field');
this.route('radio-group');
this.route('select');
this.route('text-area');
this.route('text-field');
this.route('radio-group');
});
this.route('helpers');
this.route('mktg-components', function () {
Expand Down
1 change: 1 addition & 0 deletions apps/ember-test-app/app/templates/components.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<Item @name="Select" @route="components.select" />
<Item @name="Text Area" @route="components.text-area" />
<Item @name="Text Field" @route="components.text-field" />
<Item @name="Phone Field" @route="components.phone-field" />
</:group>
</Sidebar.Group>
</Sidebar>
Expand Down
5 changes: 5 additions & 0 deletions apps/ember-test-app/app/templates/components/phone-field.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{{page-title "Phone Field"}}

<div class="container mx-auto">
<F::PhoneField />
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { fillIn, render, settled, click } from '@ember/test-helpers';
import { tracked } from '@glimmer/tracking';
import PhoneField from '@nrg-ui/ember/components/form/phone-field';
import bind from '@nrg-ui/ember/helpers/bind';
import { setupRenderingTest } from 'ember-qunit';
import { module, test } from 'qunit';

class Model {
@tracked
value: string = '';
}

async function clickAt(element: HTMLInputElement, position: number) {
element.setSelectionRange(position, position);
await click(element);
}

async function simulateBackspace(element: HTMLInputElement) {
const cursorPosition = element.selectionStart ?? -1;
const value = element.value;
element.value =
value.slice(0, cursorPosition - 1) + value.slice(cursorPosition);
element.setSelectionRange(cursorPosition - 1, cursorPosition - 1);
element.dispatchEvent(
new InputEvent('input', {
key: 'Backspace',
inputType: 'deleteContentBackward',
}),
);
await settled();
}

async function simulateDelete(element: HTMLInputElement) {
const cursorPosition = element.selectionStart ?? -1;
const value = element.value;
element.value =
value.slice(0, cursorPosition) + value.slice(cursorPosition + 1);
element.setSelectionRange(cursorPosition, cursorPosition);
element.dispatchEvent(
new InputEvent('input', {
key: 'Delete',
inputType: 'deleteContentForward',
}),
);
await settled();
}

module('Integration | Component | form/phone-field', function (hooks) {
setupRenderingTest(hooks);

test('it renders', async function (assert) {
const model = new Model();

await render(<template>
<PhoneField @binding={{bind model "value"}} />
</template>);

assert.dom('input').hasAttribute('type', 'tel').hasClass('form-control');
});

test('it displays formatted phone numbers', async function (assert) {
const model = new Model();

await render(<template>
<PhoneField @binding={{bind model "value"}} />
</template>);

await fillIn('input', '11234567890');
assert.dom('input').hasValue('+1 (123) 456-7890');

await fillIn('input', '1234567890');
assert.dom('input').hasValue('(123) 456-7890');

await fillIn('input', '4567890');
assert.dom('input').hasValue('456-7890');

await fillIn('input', '7890');
assert.dom('input').hasValue('7890');
});

test('it binds unformatted numbers', async function (assert) {
const model = new Model();

await render(<template>
<PhoneField @binding={{bind model "value"}} />
</template>);

await fillIn('input', '+1 (123) 456-7890');
assert.strictEqual(model.value, '11234567890');

await fillIn('input', '(123) 456-7890');
assert.strictEqual(model.value, '1234567890');

await fillIn('input', '456-7890');
assert.strictEqual(model.value, '4567890');
});

test('it unformats pasted inputs', async function (assert) {
const model = new Model();

await render(<template>
<PhoneField @binding={{bind model "value"}} />
</template>);

await fillIn('input', '1-12a345(6)78_90 ');
assert.dom('input').hasValue('+1 (123) 456-7890');
assert.strictEqual(model.value, '11234567890');
});

test('it reacts to changes in model', async function (assert) {
const model = new Model();

await render(<template>
<PhoneField @binding={{bind model "value"}} />
</template>);
assert.dom('input').hasValue('');
model.value = '11234567890';
await settled();
assert.dom('input').hasValue('+1 (123) 456-7890');
});

test('it allows for backspacing from end of string', async function (assert) {
const model = new Model();
model.value = '11234567890';
await render(<template>
<PhoneField @binding={{bind model "value"}} />
</template>);
const element = this.element.querySelector('input') as HTMLInputElement;
await clickAt(element, element.value.length);
for (let i = 0; i < 4; i++) {
await simulateBackspace(element);
}
assert.strictEqual(model.value, '1123456');
for (let i = 0; i < 3; i++) {
await simulateBackspace(element);
}
assert.strictEqual(model.value, '1123');
for (let i = 0; i < 3; i++) {
await simulateBackspace(element);
}
assert.strictEqual(model.value, '1');
});

test('it allows for deleting from beginning of string', async function (assert) {
const model = new Model();
model.value = '1234567890123';
await render(<template>
<PhoneField @binding={{bind model "value"}} />
</template>);
const element = this.element.querySelector('input') as HTMLInputElement;
await clickAt(element, 0);
for (let i = 0; i < 4; i++) {
await simulateDelete(element);
}
assert.strictEqual(model.value, '567890123');
for (let i = 0; i < 3; i++) {
await simulateDelete(element);
}
assert.strictEqual(model.value, '890123');
for (let i = 0; i < 3; i++) {
await simulateDelete(element);
}
assert.strictEqual(model.value, '123');
for (let i = 0; i < 3; i++) {
await simulateDelete(element);
}
assert.strictEqual(model.value, '');
});

test('it allows for backspacing from after special characters', async function (assert) {
const model = new Model();
model.value = '1234567890123';
await render(<template>
<PhoneField @binding={{bind model "value"}} />
</template>);
const element = this.element.querySelector('input') as HTMLInputElement;
await clickAt(element, 11);
await simulateBackspace(element);
assert.strictEqual(model.value, '123457890123');
assert.strictEqual(element.selectionStart, 10);

await simulateBackspace(element);
await simulateBackspace(element);
assert.strictEqual(model.value, '1237890123');
assert.strictEqual(element.selectionStart, 6);

await simulateBackspace(element);
await simulateBackspace(element);
await simulateBackspace(element);
assert.strictEqual(model.value, '7890123');
assert.strictEqual(element.selectionStart, 0);
});

test('it allows for deleting from before special characters', async function (assert) {
const model = new Model();
model.value = '1234567890123';
await render(<template>
<PhoneField @binding={{bind model "value"}} />
</template>);
const element = this.element.querySelector('input') as HTMLInputElement;
await clickAt(element, 4);
await simulateDelete(element);
assert.strictEqual(model.value, '123567890123');
assert.strictEqual(element.selectionStart, 6);

await clickAt(element, 0);
await simulateDelete(element);
assert.strictEqual(model.value, '23567890123');
assert.strictEqual(element.selectionStart, 1);
});
});
1 change: 1 addition & 0 deletions packages/ember/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@
"./components/form/bound-value.js": "./dist/_app_/components/form/bound-value.js",
"./components/form/field.js": "./dist/_app_/components/form/field.js",
"./components/form/index.js": "./dist/_app_/components/form/index.js",
"./components/form/phone-field.js": "./dist/_app_/components/form/phone-field.js",
"./components/form/radio-group.js": "./dist/_app_/components/form/radio-group.js",
"./components/form/select.js": "./dist/_app_/components/form/select.js",
"./components/form/text-area.js": "./dist/_app_/components/form/text-area.js",
Expand Down
Loading

0 comments on commit 03091e0

Please sign in to comment.