From 03091e0a434949364ceffbb5c21daccf52a7cb6a Mon Sep 17 00:00:00 2001 From: Keith Date: Wed, 21 Aug 2024 10:24:32 -0400 Subject: [PATCH] feat: Add phone field input (#72) * 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 * feat: Extract phone formats * fix: TS errors * chore: Add keyboard tests for phone input --------- Co-authored-by: Tyler Senter --- .../app/components/f/phone-field.gts | 109 +++++++++ apps/ember-test-app/app/router.ts | 3 +- .../app/templates/components.hbs | 1 + .../app/templates/components/phone-field.hbs | 5 + .../components/form/phone-field-test.gts | 211 ++++++++++++++++++ packages/ember/package.json | 1 + .../ember/src/components/form/phone-field.gts | 121 ++++++++++ packages/ember/src/utils/phone-formats.ts | 8 + 8 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 apps/ember-test-app/app/components/f/phone-field.gts create mode 100644 apps/ember-test-app/app/templates/components/phone-field.hbs create mode 100644 apps/ember-test-app/tests/integration/components/form/phone-field-test.gts create mode 100644 packages/ember/src/components/form/phone-field.gts create mode 100644 packages/ember/src/utils/phone-formats.ts diff --git a/apps/ember-test-app/app/components/f/phone-field.gts b/apps/ember-test-app/app/components/f/phone-field.gts new file mode 100644 index 00000000..a5c03eda --- /dev/null +++ b/apps/ember-test-app/app/components/f/phone-field.gts @@ -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); + } + + +} diff --git a/apps/ember-test-app/app/router.ts b/apps/ember-test-app/app/router.ts index 23043703..58cd35b0 100644 --- a/apps/ember-test-app/app/router.ts +++ b/apps/ember-test-app/app/router.ts @@ -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 () { diff --git a/apps/ember-test-app/app/templates/components.hbs b/apps/ember-test-app/app/templates/components.hbs index 3a87f051..1b1bd2a3 100644 --- a/apps/ember-test-app/app/templates/components.hbs +++ b/apps/ember-test-app/app/templates/components.hbs @@ -17,6 +17,7 @@ + diff --git a/apps/ember-test-app/app/templates/components/phone-field.hbs b/apps/ember-test-app/app/templates/components/phone-field.hbs new file mode 100644 index 00000000..800a2c00 --- /dev/null +++ b/apps/ember-test-app/app/templates/components/phone-field.hbs @@ -0,0 +1,5 @@ +{{page-title "Phone Field"}} + +
+ +
\ No newline at end of file diff --git a/apps/ember-test-app/tests/integration/components/form/phone-field-test.gts b/apps/ember-test-app/tests/integration/components/form/phone-field-test.gts new file mode 100644 index 00000000..ae3059ea --- /dev/null +++ b/apps/ember-test-app/tests/integration/components/form/phone-field-test.gts @@ -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(); + + assert.dom('input').hasAttribute('type', 'tel').hasClass('form-control'); + }); + + test('it displays formatted phone numbers', async function (assert) { + const model = new Model(); + + await render(); + + 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(); + + 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(); + + 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(); + 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(); + 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(); + 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(); + 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(); + 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); + }); +}); diff --git a/packages/ember/package.json b/packages/ember/package.json index ae0a1074..6cd87d99 100644 --- a/packages/ember/package.json +++ b/packages/ember/package.json @@ -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", diff --git a/packages/ember/src/components/form/phone-field.gts b/packages/ember/src/components/form/phone-field.gts new file mode 100644 index 00000000..0f8c3cad --- /dev/null +++ b/packages/ember/src/components/form/phone-field.gts @@ -0,0 +1,121 @@ +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { runTask } from 'ember-lifeline'; + +import TextField from './text-field.gts'; +import onInsert from '../../modifiers/did-insert.ts'; +import phoneFormats from '../../utils/phone-formats.ts'; + +function isSpecialCharacter(char: string) { + return /\D/.test(char); +} + +export default class PhoneField extends TextField { + @tracked + inputElement!: HTMLInputElement; + + @action + onInsert(element: HTMLElement) { + this.inputElement = element as HTMLInputElement; + } + + @action + change(evt: Event) { + const inputEvent = evt as InputEvent; + const target = evt.target as HTMLInputElement; + const newValue = target.value; + const currentValue = this.displayValue; + const cursorPosition = target.selectionStart ?? -1; + const isBackspace = inputEvent.inputType === 'deleteContentBackward'; + const isDelete = inputEvent.inputType === 'deleteContentForward'; + + let unformattedValue = newValue.replace(/\D/g, ''); + let characterUnderCursor = currentValue[cursorPosition ?? 0] ?? ''; + const isNonDigitCharacter = isSpecialCharacter(characterUnderCursor); + const isDeletingSpecialCharacter = + cursorPosition >= 0 && (isBackspace || isDelete) && isNonDigitCharacter; + if (isDeletingSpecialCharacter) { + if (isBackspace) { + let newCursorPosition = cursorPosition - 1; + if (isSpecialCharacter(currentValue[newCursorPosition] ?? '')) { + newCursorPosition--; + } + this.inputElement?.setSelectionRange( + newCursorPosition, + newCursorPosition, + ); + } + const beforeCursor = newValue + .substring(0, cursorPosition) + .replace(/\D/g, ''); + const afterCursor = newValue.substring(cursorPosition).replace(/\D/g, ''); + if (isBackspace) { + unformattedValue = beforeCursor.slice(0, -1) + afterCursor; + } else { + unformattedValue = beforeCursor + afterCursor.slice(1); + } + } + + this.onChange(unformattedValue); + } + + get displayValue() { + const unformattedValue = this.value?.replace(/\D/g, '') ?? ''; + let returnValue; + if (unformattedValue.length <= 4) { + returnValue = unformattedValue; + } else if (unformattedValue.length <= 7) { + returnValue = unformattedValue.replace( + phoneFormats.localInput, + phoneFormats.localOutput, + ); + } else if (unformattedValue.length <= 10) { + returnValue = unformattedValue.replace( + phoneFormats.withAreaCodeInput, + phoneFormats.withAreaCodeOutput, + ); + } else { + returnValue = unformattedValue + .substring(0, 13) + .replace( + phoneFormats.withCountryCodeInput, + phoneFormats.withCountryCodeOutput, + ); + } + + // Adjust cursor to the same relative position as before formatting + const inputValue = this.inputElement?.value ?? ''; + const cursor = this.inputElement?.selectionStart ?? inputValue.length; + if (cursor !== null || inputValue.length) { + const numbersBeforeCursor = inputValue + .substring(0, cursor) + .replace(/\D/g, '').length; + const newCursorPosition = returnValue + .split(/\d/, numbersBeforeCursor + 1) + .join('0').length; + runTask(this, () => { + this.inputElement?.setSelectionRange( + newCursorPosition, + newCursorPosition, + ); + }); + } + + return returnValue; + } + + +} diff --git a/packages/ember/src/utils/phone-formats.ts b/packages/ember/src/utils/phone-formats.ts new file mode 100644 index 00000000..cb9d9a1c --- /dev/null +++ b/packages/ember/src/utils/phone-formats.ts @@ -0,0 +1,8 @@ +export default { + localInput: /^(\d{3})(\d{0,4})$/g, + withAreaCodeInput: /^(\d{0,3})(\d{3})(\d{4})$/g, + withCountryCodeInput: /^(\d{0,3})(\d{3})(\d{3})(\d{4})$/g, + localOutput: '$1-$2', + withAreaCodeOutput: '($1) $2-$3', + withCountryCodeOutput: '+$1 ($2) $3-$4', +};