feat(aio): add `aio-select` component
Provide the functionality for select menus in a single reusable component.
This commit is contained in:
parent
a39f7d63bb
commit
c9b930dd82
|
@ -0,0 +1,16 @@
|
|||
<div class="form-select-menu">
|
||||
<button class="form-select-button" (click)="toggleOptions()">
|
||||
<strong>{{label}}</strong><span *ngIf="showSymbol" class="symbol {{selected?.value}}"></span>{{selected?.title}}
|
||||
</button>
|
||||
<ul class="form-select-dropdown" *ngIf="showOptions">
|
||||
<li *ngFor="let option of options; index as i"
|
||||
[class.selected]="option === selected"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
(click)="select(option, i)"
|
||||
(keydown.enter)="select(option, i)"
|
||||
(keydown.space)="select(option, i); $event.preventDefault()">
|
||||
<span *ngIf="showSymbol" class="symbol {{option.value}}"></span>{{option.title}}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
|
@ -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<HostComponent>;
|
||||
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 `<span>` 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: `
|
||||
<aio-select (change)="onChange($event)"
|
||||
[options]="options"
|
||||
[selected]="selected"
|
||||
[label]="label"
|
||||
[showSymbol]="showSymbol">
|
||||
</aio-select>`
|
||||
})
|
||||
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);
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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 {}
|
|
@ -27,3 +27,4 @@
|
|||
@import 'search-results';
|
||||
@import 'subsection';
|
||||
@import 'toc';
|
||||
@import 'select-menu';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue