diff --git a/README.md b/README.md index b00c464..2ccb570 100644 --- a/README.md +++ b/README.md @@ -36,14 +36,24 @@ export class AppModule { } ``` -**Credit Card Formater** +**Credit Card Formatter** * add `ccNumber` directive: ```html ``` * this will also apply a class name based off the card `.visa`, `.amex`, etc. See the array of card types in `credit-card.ts` for all available types -**Expiration Date Formater** +* You can get parsed card type by using export api: + +```html + +{{ccNumber.resolvedScheme$ | async}} +``` + +`resolvedScheme$` will be populated with `visa`, `amex`, etc. + + +**Expiration Date Formatter** Will support format of MM/YY or MM/YYYY * add `ccExp` directive: ```html @@ -96,7 +106,7 @@ export class AppComponent implements OnInit { # Inspiration -Based on Stripe's [jquery.payment](https://github.com/stripe/jquery.payment) plugin but adapted for use by Angular2 +Based on Stripe's [jquery.payment](https://github.com/stripe/jquery.payment) plugin but adapted for use by Angular # License diff --git a/projects/angular-cc-library/src/lib/directives/credit-card-format.directive.spec.ts b/projects/angular-cc-library/src/lib/directives/credit-card-format.directive.spec.ts index 7bce233..9b0bfdf 100644 --- a/projects/angular-cc-library/src/lib/directives/credit-card-format.directive.spec.ts +++ b/projects/angular-cc-library/src/lib/directives/credit-card-format.directive.spec.ts @@ -1,139 +1,170 @@ import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; -import {CreditCardFormatDirective} from './credit-card-format.directive'; +import { CreditCardFormatDirective } from './credit-card-format.directive'; import { Component, DebugElement } from '@angular/core'; import { By } from '@angular/platform-browser'; -@Component({ - template: ``, -}) -class TestCreditCardFormatComponent { +const KEY_MAP = { + ONE: 49, // input `1` + BACKSPACE: 8, +}; + +function createKeyEvent(keyCode: number) { + return {keyCode, which: keyCode, preventDefault: jest.fn()}; +} + +function triggerKeyEvent(input: DebugElement, eventName: string, keyCode: number) { + input.triggerEventHandler(eventName, createKeyEvent(keyCode)); } + describe('Directive: CreditCardFormat', () => { - let component: TestCreditCardFormatComponent; - let fixture: ComponentFixture; - let inputEl: DebugElement; - beforeEach(() => { - TestBed.configureTestingModule({ - declarations: [TestCreditCardFormatComponent, CreditCardFormatDirective], + describe('general cases', () => { + @Component({ + template: ``, + }) + class TestCreditCardFormatComponent {} + + let fixture: ComponentFixture; + let inputEl: DebugElement; + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [TestCreditCardFormatComponent, CreditCardFormatDirective], + }); + fixture = TestBed.createComponent(TestCreditCardFormatComponent); + inputEl = fixture.debugElement.query(By.css('input')); }); - fixture = TestBed.createComponent(TestCreditCardFormatComponent); - component = fixture.componentInstance; - inputEl = fixture.debugElement.query(By.css('input')); - }); - it('formats card number tick by tick', fakeAsync(() => { + it('formats card number tick by tick', fakeAsync(() => { - inputEl.nativeElement.value = '4111 1111'; - inputEl.triggerEventHandler('keydown', {keyCode: 49, which: 49}); - fixture.detectChanges(); - tick(10); - expect(inputEl.nativeElement.value).toBe('4111 1111'); + inputEl.nativeElement.value = '4111 1111'; + triggerKeyEvent(inputEl, 'keydown', KEY_MAP.ONE); - inputEl.triggerEventHandler('keypress', {keyCode: 49, which: 49}); - fixture.detectChanges(); - tick(10); - expect(inputEl.nativeElement.value).toBe('4111 1111'); + fixture.detectChanges(); + tick(10); + expect(inputEl.nativeElement.value).toBe('4111 1111'); - // the value is changed here by the browser as default behavior - inputEl.nativeElement.value = '4111 11111'; + triggerKeyEvent(inputEl, 'keypress', KEY_MAP.ONE); + fixture.detectChanges(); + tick(10); + expect(inputEl.nativeElement.value).toBe('4111 1111'); - inputEl.nativeElement.focus(); - inputEl.triggerEventHandler('input', null); - fixture.detectChanges(); - tick(10); - expect(inputEl.nativeElement.value).toBe('4111 1111 1'); + // the value is changed here by the browser as default behavior + inputEl.nativeElement.value = '4111 11111'; - inputEl.triggerEventHandler('keyup', {keyCode: 49, which: 49}); - fixture.detectChanges(); - tick(10); - expect(inputEl.nativeElement.value).toBe('4111 1111 1'); + inputEl.nativeElement.focus(); + inputEl.triggerEventHandler('input', null); + fixture.detectChanges(); + tick(10); + expect(inputEl.nativeElement.value).toBe('4111 1111 1'); + triggerKeyEvent(inputEl, 'keyup', KEY_MAP.ONE); - })); + fixture.detectChanges(); + tick(10); + expect(inputEl.nativeElement.value).toBe('4111 1111 1'); + })); - it('formats card number one tick', fakeAsync(() => { + it('formats card number one tick', fakeAsync(() => { - inputEl.nativeElement.value = '4111 1111'; - inputEl.triggerEventHandler('keydown', {keyCode: 49, which: 49}); - fixture.detectChanges(); - expect(inputEl.nativeElement.value).toBe('4111 1111'); + inputEl.nativeElement.value = '4111 1111'; + inputEl.triggerEventHandler('keydown', {keyCode: KEY_MAP.ONE, which: KEY_MAP.ONE}); + fixture.detectChanges(); + expect(inputEl.nativeElement.value).toBe('4111 1111'); - inputEl.triggerEventHandler('keypress', {keyCode: 49, which: 49}); - fixture.detectChanges(); - expect(inputEl.nativeElement.value).toBe('4111 1111'); + inputEl.triggerEventHandler('keypress', {keyCode: KEY_MAP.ONE, which: KEY_MAP.ONE}); + fixture.detectChanges(); + expect(inputEl.nativeElement.value).toBe('4111 1111'); - // the value is changed here by the browser as default behavior - inputEl.nativeElement.value = '4111 11111'; + // the value is changed here by the browser as default behavior + inputEl.nativeElement.value = '4111 11111'; - inputEl.triggerEventHandler('input', null); - fixture.detectChanges(); - expect(inputEl.nativeElement.value).toBe('4111 11111'); + inputEl.triggerEventHandler('input', null); + fixture.detectChanges(); + expect(inputEl.nativeElement.value).toBe('4111 1111 1'); + })); - inputEl.nativeElement.focus(); - inputEl.triggerEventHandler('keyup', {keyCode: 49, which: 49}); - fixture.detectChanges(); - expect(inputEl.nativeElement.value).toBe('4111 11111'); + it('deletes from middle of value', fakeAsync(() => { - tick(10); - expect(inputEl.nativeElement.value).toBe('4111 1111 1'); + inputEl.nativeElement.value = '4111 1111 111'; + inputEl.nativeElement.selectionStart = 5; + inputEl.nativeElement.selectionEnd = 5; + inputEl.nativeElement.focus(); - })); + const event = createKeyEvent(KEY_MAP.BACKSPACE); - it('deletes from middle of value', fakeAsync(() => { + inputEl.triggerEventHandler('keydown', event); + fixture.detectChanges(); + tick(10); + expect(inputEl.nativeElement.value).toBe('4111 1111 11'); + expect(inputEl.nativeElement.selectionStart).toBe(3); + expect(inputEl.nativeElement.selectionEnd).toBe(3); + expect(event.preventDefault).toBeCalled(); - inputEl.nativeElement.value = '4111 1111 111'; - inputEl.nativeElement.selectionStart = 5; - inputEl.nativeElement.selectionEnd = 5; - inputEl.nativeElement.focus(); + })); - let defPrevented = false; + it('deletes from beginning of value', fakeAsync(() => { - inputEl.triggerEventHandler('keydown', {keyCode: 8, which: 8, preventDefault() { defPrevented = true; }}); - fixture.detectChanges(); - tick(10); - expect(inputEl.nativeElement.value).toBe('4111 1111 11'); - expect(inputEl.nativeElement.selectionStart).toBe(3); - expect(inputEl.nativeElement.selectionEnd).toBe(3); - expect(defPrevented).toBeTruthy(); + inputEl.nativeElement.value = '5 411 1111'; + inputEl.nativeElement.selectionStart = 2; + inputEl.nativeElement.selectionEnd = 2; + inputEl.nativeElement.focus(); - })); + const event = createKeyEvent(KEY_MAP.BACKSPACE); - it('deletes from beginning of value', fakeAsync(() => { + inputEl.triggerEventHandler('keydown', event); + fixture.detectChanges(); + tick(10); + expect(inputEl.nativeElement.value).toBe('4111 111'); + expect(inputEl.nativeElement.selectionStart).toBe(0); + expect(inputEl.nativeElement.selectionEnd).toBe(0); + expect(event.preventDefault).toBeCalled(); - inputEl.nativeElement.value = '5 411 1111'; - inputEl.nativeElement.selectionStart = 2; - inputEl.nativeElement.selectionEnd = 2; - inputEl.nativeElement.focus(); + })); - let defPrevented = false; + it('does not modify deleting from end of value', fakeAsync(() => { - inputEl.triggerEventHandler('keydown', {keyCode: 8, which: 8, preventDefault() { defPrevented = true; }}); - fixture.detectChanges(); - tick(10); - expect(inputEl.nativeElement.value).toBe('4111 111'); - expect(inputEl.nativeElement.selectionStart).toBe(0); - expect(inputEl.nativeElement.selectionEnd).toBe(0); - expect(defPrevented).toBeTruthy(); + inputEl.nativeElement.value = '4111 1111 111'; + inputEl.nativeElement.selectionStart = 13; + inputEl.nativeElement.selectionEnd = 13; + inputEl.nativeElement.focus(); - })); + const event = createKeyEvent(KEY_MAP.BACKSPACE); + inputEl.triggerEventHandler('keydown', event); + fixture.detectChanges(); + tick(10); + expect(inputEl.nativeElement.value).toBe('4111 1111 111'); + expect(inputEl.nativeElement.selectionStart).toBe(13); + expect(inputEl.nativeElement.selectionEnd).toBe(13); + expect(event.preventDefault).not.toBeCalled(); - it('does not modify deleting from end of value', fakeAsync(() => { - - inputEl.nativeElement.value = '4111 1111 111'; - inputEl.nativeElement.selectionStart = 13; - inputEl.nativeElement.selectionEnd = 13; - inputEl.nativeElement.focus(); + })); + }); - let defPrevented = false; + describe('exportAs cases', () => { + @Component({ + template: ` + {{ccNumber.resolvedScheme$ | async}}`, + }) + class TestCreditCardFormatComponent {} + + let fixture: ComponentFixture; + let inputEl: DebugElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [TestCreditCardFormatComponent, CreditCardFormatDirective], + }); + fixture = TestBed.createComponent(TestCreditCardFormatComponent); + inputEl = fixture.debugElement.query(By.css('input')); + }); - inputEl.triggerEventHandler('keydown', {keyCode: 8, which: 8, preventDefault() { defPrevented = true; }}); - fixture.detectChanges(); - tick(10); - expect(inputEl.nativeElement.value).toBe('4111 1111 111'); - expect(inputEl.nativeElement.selectionStart).toBe(13); - expect(inputEl.nativeElement.selectionEnd).toBe(13); - expect(defPrevented).toBeFalsy(); + it('should provide resolved scheme via exportAs', () => { + (inputEl.nativeElement as HTMLInputElement).value = '4111111111111111'; + inputEl.triggerEventHandler('input', null); + fixture.detectChanges(); - })); + const span: HTMLSpanElement = fixture.debugElement.query(By.css('.scheme')).nativeElement; + expect(span.textContent).toBe('visa'); + }); + }); }); diff --git a/projects/angular-cc-library/src/lib/directives/credit-card-format.directive.ts b/projects/angular-cc-library/src/lib/directives/credit-card-format.directive.ts index 7886c7e..2a35f8b 100644 --- a/projects/angular-cc-library/src/lib/directives/credit-card-format.directive.ts +++ b/projects/angular-cc-library/src/lib/directives/credit-card-format.directive.ts @@ -1,14 +1,18 @@ import { Directive, ElementRef, HostListener, Optional, Self } from '@angular/core'; import { CreditCard } from '../credit-card'; import { NgControl } from '@angular/forms'; +import { BehaviorSubject } from 'rxjs'; @Directive({ - selector: '[ccNumber]', + selector: 'input[ccNumber]', + exportAs: 'ccNumber', }) export class CreditCardFormatDirective { - public target: HTMLInputElement; + private target: HTMLInputElement; private cards = CreditCard.cards(); + public resolvedScheme$ = new BehaviorSubject('unknown'); + constructor( private el: ElementRef, @Self() @Optional() private control: NgControl, @@ -67,28 +71,15 @@ export class CreditCardFormatDirective { } private formatCardNumber(e: KeyboardEvent) { - let card; - let digit; - let length; - let upperLength; - let value; - - digit = String.fromCharCode(e.which); + const digit = String.fromCharCode(e.which); if (!/^\d+$/.test(digit)) { return; } - value = this.target.value; - - card = CreditCard.cardFromNumber(value + digit); - - length = (value.replace(/\D/g, '') + digit).length; - - upperLength = 19; - - if (card) { - upperLength = card.length[card.length.length - 1]; - } + const value = this.target.value; + const card = CreditCard.cardFromNumber(value + digit); + const length = (value.replace(/\D/g, '') + digit).length; + const upperLength = card ? card.length[card.length.length - 1] : 19; if (length >= upperLength) { return; @@ -122,15 +113,14 @@ export class CreditCardFormatDirective { } private setCardType() { - let card; - const val = this.target.value; - const cardType = CreditCard.cardType(val) || 'unknown'; + const cardType = CreditCard.cardType(this.target.value) || 'unknown'; + + this.resolvedScheme$.next(cardType); if (!this.target.classList.contains(cardType)) { - for (let i = 0, len = this.cards.length; i < len; i++) { - card = this.cards[i]; + this.cards.forEach((card) => { this.target.classList.remove(card.type); - } + }); this.target.classList.remove('unknown'); this.target.classList.add(cardType); @@ -139,15 +129,14 @@ export class CreditCardFormatDirective { } private reFormatCardNumber() { - setTimeout(() => { - let value = CreditCard.replaceFullWidthChars(this.target.value); - value = CreditCard.formatCardNumber(value); - const oldValue = this.target.value; - if (value !== oldValue) { - this.target.selectionStart = this.target.selectionEnd = CreditCard.safeVal(value, this.target, (safeVal => { - this.updateValue(safeVal); - })); - } - }); + const value = CreditCard.formatCardNumber( + CreditCard.replaceFullWidthChars(this.target.value), + ); + const oldValue = this.target.value; + if (value !== oldValue) { + this.target.selectionStart = this.target.selectionEnd = CreditCard.safeVal(value, this.target, (safeVal => { + this.updateValue(safeVal); + })); + } } } diff --git a/projects/angular-cc-library/src/lib/directives/cvc-format.directive.ts b/projects/angular-cc-library/src/lib/directives/cvc-format.directive.ts index 197886b..cf62aff 100644 --- a/projects/angular-cc-library/src/lib/directives/cvc-format.directive.ts +++ b/projects/angular-cc-library/src/lib/directives/cvc-format.directive.ts @@ -3,11 +3,10 @@ import { CreditCard } from '../credit-card'; import { NgControl } from '@angular/forms'; @Directive({ - selector: '[ccCVC]', + selector: 'input[ccCVC]', }) - export class CvcFormatDirective { - public target: HTMLInputElement; + private target: HTMLInputElement; constructor( private el: ElementRef, @@ -36,28 +35,17 @@ export class CvcFormatDirective { } @HostListener('paste') - public onPaste() { - this.reformatCvc(); - } @HostListener('change') - public onChange() { - this.reformatCvc(); - } @HostListener('input') - public onInput() { - this.reformatCvc(); - } - - private reformatCvc() { - setTimeout(() => { - let val = CreditCard.replaceFullWidthChars(this.target.value); - val = val.replace(/\D/g, '').slice(0, 4); - const oldVal = this.target.value; - if (val !== oldVal) { - this.target.selectionStart = this.target.selectionEnd = CreditCard.safeVal(val, this.target, (safeVal => { - this.updateValue(safeVal); - })); - } - }); + public reformatCvc() { + const val = CreditCard.replaceFullWidthChars(this.target.value) + .replace(/\D/g, '') + .slice(0, 4); + const oldVal = this.target.value; + if (val !== oldVal) { + this.target.selectionStart = this.target.selectionEnd = CreditCard.safeVal(val, this.target, (safeVal => { + this.updateValue(safeVal); + })); + } } } diff --git a/projects/angular-cc-library/src/lib/directives/expiry-format.directive.ts b/projects/angular-cc-library/src/lib/directives/expiry-format.directive.ts index 9cf7b3c..83de034 100644 --- a/projects/angular-cc-library/src/lib/directives/expiry-format.directive.ts +++ b/projects/angular-cc-library/src/lib/directives/expiry-format.directive.ts @@ -3,10 +3,10 @@ import { CreditCard } from '../credit-card'; import { NgControl } from '@angular/forms'; @Directive({ - selector: '[ccExp]', + selector: 'input[ccExp]', }) export class ExpiryFormatDirective { - public target: HTMLInputElement; + private target: HTMLInputElement; constructor( private el: ElementRef, @@ -63,23 +63,22 @@ export class ExpiryFormatDirective { const val = `${this.target.value}${digit}`; if (!/^\d+$/.test(digit)) { - if (/^\d$/.test(val) && (val !== '0' && val !== '1')) { - e.preventDefault(); - setTimeout(() => { - this.updateValue(`0${val} / `); - }); - } else if (/^\d\d$/.test(val)) { - e.preventDefault(); - setTimeout(() => { - const m1 = parseInt(val[0], 10); - const m2 = parseInt(val[1], 10); - if (m2 > 2 && m1 !== 0) { - this.updateValue(`0${m1} / ${m2}`); - } else { - this.updateValue(`${val} / `); - } - }); + return; + } + + if (/^\d$/.test(val) && (val !== '0' && val !== '1')) { + e.preventDefault(); + this.updateValue(`0${val} / `); + } else if (/^\d\d$/.test(val)) { + e.preventDefault(); + const m1 = parseInt(val[0], 10); + const m2 = parseInt(val[1], 10); + if (m2 > 2 && m1 !== 0) { + this.updateValue(`0${m1} / ${m2}`); + } else { + this.updateValue(`${val} / `); } + } } @@ -115,24 +114,20 @@ export class ExpiryFormatDirective { } if (/\d\s\/\s$/.test(val)) { e.preventDefault(); - setTimeout(() => { - this.updateValue(val.replace(/\d\s\/\s$/, '')); - }); + this.updateValue(val.replace(/\d\s\/\s$/, '')); } } private reformatExpiry() { - setTimeout(() => { - let val = this.target.value; - val = CreditCard.replaceFullWidthChars(val); - val = CreditCard.formatExpiry(val); - const oldVal = this.target.value; - if (val !== oldVal) { - this.target.selectionStart = this.target.selectionEnd = CreditCard.safeVal(val, this.target, (safeVal => { - this.updateValue(safeVal); - })); - } - }); + const val = CreditCard.formatExpiry( + CreditCard.replaceFullWidthChars(this.target.value), + ); + + const oldVal = this.target.value; + if (val !== oldVal) { + this.target.selectionStart = this.target.selectionEnd = CreditCard.safeVal(val, this.target, (safeVal => { + this.updateValue(safeVal); + })); + } } - } diff --git a/tslint.json b/tslint.json index 7b3cfb5..ccdd5f6 100644 --- a/tslint.json +++ b/tslint.json @@ -1,6 +1,7 @@ { "extends": "tslint:recommended", "rules": { + "ban": [true, "fdescribe"], "array-type": false, "arrow-parens": false, "deprecation": false,