From c9b930dd82178055618c96910f88af20622fb97d Mon Sep 17 00:00:00 2001 From: Peter Bacon Darwin Date: Wed, 7 Jun 2017 17:16:41 +0100 Subject: [PATCH] feat(aio): add `aio-select` component Provide the functionality for select menus in a single reusable component. --- .../app/shared/select/select.component.html | 16 ++ .../shared/select/select.component.spec.ts | 165 ++++++++++++++++++ aio/src/app/shared/select/select.component.ts | 62 +++++++ aio/src/app/shared/shared.module.ts | 16 ++ aio/src/styles/2-modules/_modules-dir.scss | 1 + aio/src/styles/2-modules/_select-menu.scss | 72 ++++++++ 6 files changed, 332 insertions(+) create mode 100644 aio/src/app/shared/select/select.component.html create mode 100644 aio/src/app/shared/select/select.component.spec.ts create mode 100644 aio/src/app/shared/select/select.component.ts create mode 100644 aio/src/app/shared/shared.module.ts create mode 100644 aio/src/styles/2-modules/_select-menu.scss diff --git a/aio/src/app/shared/select/select.component.html b/aio/src/app/shared/select/select.component.html new file mode 100644 index 0000000000..54974b82c0 --- /dev/null +++ b/aio/src/app/shared/select/select.component.html @@ -0,0 +1,16 @@ +
+ + +
diff --git a/aio/src/app/shared/select/select.component.spec.ts b/aio/src/app/shared/select/select.component.spec.ts new file mode 100644 index 0000000000..5efaed1e50 --- /dev/null +++ b/aio/src/app/shared/select/select.component.spec.ts @@ -0,0 +1,165 @@ +import { Component, DebugElement } from '@angular/core'; +import { async, ComponentFixture, TestBed, inject } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { SelectComponent, Option } from './select.component'; + +const options = [ + { title: 'Option A', value: 'option-a' }, + { title: 'Option B', value: 'option-b' } +]; + +let component: SelectComponent; +let host: HostComponent; +let fixture: ComponentFixture; +let element: DebugElement; + +describe('SelectComponent', () => { + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ SelectComponent, HostComponent ], + }); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(HostComponent); + host = fixture.componentInstance; + element = fixture.debugElement.query(By.directive(SelectComponent)); + component = element.componentInstance; + }); + + describe('(initially)', () => { + it('should show the button and no options', () => { + expect(getButton()).toBeDefined(); + expect(getOptionContainer()).toEqual(null); + }); + }); + + describe('button', () => { + it('should display the label if provided', () => { + expect(getButton().textContent.trim()).toEqual(''); + host.label = 'Label:'; + fixture.detectChanges(); + expect(getButton().textContent.trim()).toEqual('Label:'); + }); + + it('should contain a symbol `` if hasSymbol is true', () => { + expect(getButton().querySelector('span')).toEqual(null); + host.showSymbol = true; + fixture.detectChanges(); + const span = getButton().querySelector('span'); + expect(span).not.toEqual(null); + expect(span.className).toContain('symbol'); + }); + + it('should display the selected option, if there is one', () => { + host.showSymbol = true; + host.selected = options[0]; + fixture.detectChanges(); + expect(getButton().textContent).toContain(options[0].title); + expect(getButton().querySelector('span').className).toContain(options[0].value); + }); + + it('should toggle the visibility of the options list when clicked', () => { + host.options = options; + getButton().click(); + fixture.detectChanges(); + expect(getOptionContainer()).not.toEqual(null); + getButton().click(); + fixture.detectChanges(); + expect(getOptionContainer()).toEqual(null); + }); + }); + + describe('options list', () => { + beforeEach(() => { + host.options = options; + host.showSymbol = true; + getButton().click(); // ensure the the options are visible + fixture.detectChanges(); + }); + + it('should show the corresponding title of each option', () => { + getOptions().forEach((li, index) => { + expect(li.textContent).toContain(options[index].title); + }); + }); + + it('should select the option that is clicked', () => { + getOptions()[0].click(); + fixture.detectChanges(); + expect(host.onChange).toHaveBeenCalledWith({ option: options[0], index: 0 }); + expect(getButton().textContent).toContain(options[0].title); + expect(getButton().querySelector('span').className).toContain(options[0].value); + }); + + it('should select the current option when enter is pressed', () => { + const e = new KeyboardEvent('keydown', {bubbles: true, cancelable: true, key: 'Enter'}); + getOptions()[0].dispatchEvent(e); + fixture.detectChanges(); + expect(host.onChange).toHaveBeenCalledWith({ option: options[0], index: 0 }); + expect(getButton().textContent).toContain(options[0].title); + expect(getButton().querySelector('span').className).toContain(options[0].value); + }); + + it('should select the current option when space is pressed', () => { + const e = new KeyboardEvent('keydown', {bubbles: true, cancelable: true, key: ' '}); + getOptions()[0].dispatchEvent(e); + fixture.detectChanges(); + expect(host.onChange).toHaveBeenCalledWith({ option: options[0], index: 0 }); + expect(getButton().textContent).toContain(options[0].title); + expect(getButton().querySelector('span').className).toContain(options[0].value); + }); + + it('should hide when an option is clicked', () => { + getOptions()[0].click(); + fixture.detectChanges(); + expect(getOptionContainer()).toEqual(null); + }); + + it('should hide when there is a click that is not on the option list', () => { + fixture.nativeElement.click(); + fixture.detectChanges(); + expect(getOptionContainer()).toEqual(null); + }); + + it('should hide if the escape button is pressed', () => { + const e = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Escape' }); + document.dispatchEvent(e); + fixture.detectChanges(); + expect(getOptionContainer()).toEqual(null); + }); + }); +}); + + + +@Component({ + template: ` + + ` +}) +class HostComponent { + onChange = jasmine.createSpy('onChange'); + options: Option[]; + selected: Option; + label: string; + showSymbol: boolean; +} + +function getButton(): HTMLButtonElement { + return element.query(By.css('button')).nativeElement; +} + +function getOptionContainer(): HTMLUListElement { + const de = element.query(By.css('ul')); + return de && de.nativeElement; +} + +function getOptions(): HTMLLIElement[] { + return element.queryAll(By.css('li')).map(de => de.nativeElement); +} diff --git a/aio/src/app/shared/select/select.component.ts b/aio/src/app/shared/select/select.component.ts new file mode 100644 index 0000000000..11bc9913df --- /dev/null +++ b/aio/src/app/shared/select/select.component.ts @@ -0,0 +1,62 @@ +import { Component, ElementRef, EventEmitter, HostListener, Input, Output, OnInit } from '@angular/core'; + +export interface Option { + title: string; + value?: any; +} + +@Component({ + selector: 'aio-select', + templateUrl: 'select.component.html' +}) +export class SelectComponent implements OnInit { + @Input() + selected: Option; + + @Input() + options: Option[]; + + @Output() + change = new EventEmitter<{option: Option, index: number}>(); + + @Input() + showSymbol = false; + + @Input() + label: string; + + showOptions = false; + + constructor(private hostElement: ElementRef) {} + + ngOnInit() { + this.label = this.label || ''; + } + + toggleOptions() { + this.showOptions = !this.showOptions; + } + + hideOptions() { + this.showOptions = false; + } + + select(option: Option, index: number) { + this.selected = option; + this.change.emit({option, index}); + this.hideOptions(); + } + + @HostListener('document:click', ['$event.target']) + onClick(eventTarget: HTMLElement) { + // Hide the options if we clicked outside the component + if (!this.hostElement.nativeElement.contains(eventTarget)) { + this.hideOptions(); + } + } + + @HostListener('document:keydown.escape') + onKeyDown() { + this.hideOptions(); + } +} diff --git a/aio/src/app/shared/shared.module.ts b/aio/src/app/shared/shared.module.ts new file mode 100644 index 0000000000..2f7eafc0d3 --- /dev/null +++ b/aio/src/app/shared/shared.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SelectComponent } from './select/select.component'; + +@NgModule({ + imports: [ + CommonModule + ], + exports: [ + SelectComponent + ], + declarations: [ + SelectComponent + ] +}) +export class SharedModule {} diff --git a/aio/src/styles/2-modules/_modules-dir.scss b/aio/src/styles/2-modules/_modules-dir.scss index e75eeff395..c2ac7b096c 100644 --- a/aio/src/styles/2-modules/_modules-dir.scss +++ b/aio/src/styles/2-modules/_modules-dir.scss @@ -27,3 +27,4 @@ @import 'search-results'; @import 'subsection'; @import 'toc'; + @import 'select-menu'; diff --git a/aio/src/styles/2-modules/_select-menu.scss b/aio/src/styles/2-modules/_select-menu.scss new file mode 100644 index 0000000000..a93fa753e9 --- /dev/null +++ b/aio/src/styles/2-modules/_select-menu.scss @@ -0,0 +1,72 @@ +/* SELECT MENU */ + +.form-select-menu { + position: relative; +} + +.form-select-button { + background: $white; + box-shadow: 0 2px 2px rgba($black, 0.24), 0 0 2px rgba($black, 0.12); + box-sizing: border-box; + border: 1px solid $white; + color: $blue-grey-600; + font-size: 12px; + font-weight: 400; + height: 32px; + line-height: 32px; + outline: none; + padding: 0 16px; + text-align: left; + width: 100%; + cursor: pointer; + + strong { + font-weight: 600; + margin-right: 8px; + text-transform: uppercase; + } + + &:focus { + border: 1px solid $blue-400; + box-shadow: 0 2px 2px rgba($blue-400, 0.24), 0 0 2px rgba($blue-400, 0.12); + } +} + +.form-select-dropdown { + background: $white; + box-shadow: 0 16px 16px rgba($black, 0.24), 0 0 16px rgba($black, 0.12); + border-radius: 4px; + list-style-type: none; + margin: 0; + padding: 0; + position: absolute; + top: 0; + width: 100%; + z-index: $layer-2; + + li { + cursor: pointer; + font-size: 14px; + line-height: 32px; + margin: 0; + padding: 0 16px 0 40px; + position: relative; + transition: all .2s; + + &:hover { + background: $blue-grey-50; + color: $blue-500; + } + + &.selected { + background-color: $blue-grey-100; + } + + .symbol { + left: 16px; + position: absolute; + top: 8px; + z-index: $layer-5; + } + } +}