mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
feat(select): basic selection and animations (#1647)
This commit is contained in:
@@ -1 +1,5 @@
|
||||
<md-select></md-select>
|
||||
<div class="demo-select">
|
||||
<md-select placeholder="Food">
|
||||
<md-option *ngFor="let food of foods"> {{ food.viewValue }} </md-option>
|
||||
</md-select>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
.demo-select {
|
||||
}
|
||||
@@ -8,5 +8,9 @@ import {Component} from '@angular/core';
|
||||
styleUrls: ['select-demo.css'],
|
||||
})
|
||||
export class SelectDemo {
|
||||
|
||||
foods = [
|
||||
{value: 'steak', viewValue: 'Steak'},
|
||||
{value: 'pizza', viewValue: 'Pizza'},
|
||||
{value: 'tacos', viewValue: 'Tacos'}
|
||||
];
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ export class ListKeyManager {
|
||||
return this._tabOut.asObservable();
|
||||
}
|
||||
|
||||
get focusedItemIndex(): number {
|
||||
return this._focusedItemIndex;
|
||||
}
|
||||
|
||||
set focusedItemIndex(value: number) {
|
||||
this._focusedItemIndex = value;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export const ELEMENTS_SELECTOR = `
|
||||
md-list-item,
|
||||
md-menu,
|
||||
md-nav-list,
|
||||
md-option,
|
||||
md-placeholder,
|
||||
md-progress-bar,
|
||||
md-progress-circle,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
## Work in progress!
|
||||
|
||||
The select is still a work in progress, so most features have not been implemented. Not ready for use!
|
||||
@@ -2,5 +2,50 @@
|
||||
@import '../core/theming/theming';
|
||||
|
||||
@mixin md-select-theme($theme) {
|
||||
$foreground: map-get($theme, foreground);
|
||||
$background: map-get($theme, background);
|
||||
$primary: map-get($theme, primary);
|
||||
|
||||
.md-select-trigger {
|
||||
color: md-color($foreground, hint-text);
|
||||
border-bottom: 1px solid md-color($foreground, divider);
|
||||
|
||||
md-select:focus & {
|
||||
border-bottom: 1px solid md-color($primary);
|
||||
}
|
||||
}
|
||||
|
||||
.md-select-placeholder {
|
||||
md-select:focus & {
|
||||
color: md-color($primary);
|
||||
}
|
||||
}
|
||||
|
||||
.md-select-arrow {
|
||||
color: md-color($foreground, hint-text);
|
||||
|
||||
md-select:focus & {
|
||||
color: md-color($primary);
|
||||
}
|
||||
}
|
||||
|
||||
.md-select-content {
|
||||
background: md-color($background, card);
|
||||
}
|
||||
|
||||
.md-select-value {
|
||||
color: md-color($foreground, text);
|
||||
}
|
||||
|
||||
md-option {
|
||||
&:hover, &:focus {
|
||||
background: md-color($background, hover);
|
||||
}
|
||||
|
||||
&.md-selected {
|
||||
background: md-color($background, hover);
|
||||
color: md-color($primary);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import {NgModule, ModuleWithProviders} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {MdSelect} from './select';
|
||||
import {MdOption} from './option';
|
||||
import {OverlayModule} from '../core/overlay/overlay-directives';
|
||||
import {MdRippleModule} from '../core/ripple/ripple';
|
||||
import {OVERLAY_PROVIDERS} from '../core/overlay/overlay';
|
||||
export * from './select';
|
||||
|
||||
@NgModule({
|
||||
imports: [],
|
||||
imports: [CommonModule, OverlayModule, MdRippleModule],
|
||||
exports: [MdSelect, MdOption],
|
||||
declarations: [MdSelect, MdOption],
|
||||
})
|
||||
@@ -12,7 +16,7 @@ export class MdSelectModule {
|
||||
static forRoot(): ModuleWithProviders {
|
||||
return {
|
||||
ngModule: MdSelectModule,
|
||||
providers: []
|
||||
providers: [OVERLAY_PROVIDERS]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<ng-content></ng-content>
|
||||
<div class="md-option-ripple" md-ripple md-ripple-background-color="rgba(0,0,0,0)"
|
||||
[md-ripple-trigger]="_getHostElement()"></div>
|
||||
@@ -1,10 +1,75 @@
|
||||
import {Component, ViewEncapsulation} from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
Output,
|
||||
Renderer,
|
||||
ViewEncapsulation
|
||||
} from '@angular/core';
|
||||
import {ENTER, SPACE} from '../core/keyboard/keycodes';
|
||||
|
||||
@Component({
|
||||
moduleId: module.id,
|
||||
selector: 'md-option',
|
||||
template: ``,
|
||||
host: {
|
||||
'role': 'option',
|
||||
'tabindex': '0',
|
||||
'[class.md-selected]': 'selected',
|
||||
'[attr.aria-selected]': 'selected.toString()',
|
||||
'(click)': 'select()',
|
||||
'(keydown)': '_handleKeydown($event)'
|
||||
},
|
||||
templateUrl: 'option.html',
|
||||
styleUrls: ['select.css'],
|
||||
encapsulation: ViewEncapsulation.None
|
||||
})
|
||||
export class MdOption {}
|
||||
export class MdOption {
|
||||
private _selected = false;
|
||||
|
||||
/** Event emitted when the option is selected. */
|
||||
@Output() onSelect = new EventEmitter();
|
||||
|
||||
constructor(private _element: ElementRef, private _renderer: Renderer) {}
|
||||
|
||||
/** Whether or not the option is currently selected. */
|
||||
get selected(): boolean {
|
||||
return this._selected;
|
||||
}
|
||||
|
||||
/**
|
||||
* The displayed value of the option. It is necessary to show the selected option in the
|
||||
* select's trigger.
|
||||
* TODO(kara): Add input property alternative for node envs.
|
||||
*/
|
||||
get viewValue(): string {
|
||||
return this._getHostElement().textContent.trim();
|
||||
}
|
||||
|
||||
/** Selects the option. */
|
||||
select(): void {
|
||||
this._selected = true;
|
||||
this.onSelect.emit();
|
||||
}
|
||||
|
||||
/** Deselects the option. */
|
||||
deselect(): void {
|
||||
this._selected = false;
|
||||
}
|
||||
|
||||
/** Sets focus onto this option. */
|
||||
focus(): void {
|
||||
this._renderer.invokeElementMethod(this._getHostElement(), 'focus');
|
||||
}
|
||||
|
||||
/** Ensures the option is selected when activated from the keyboard. */
|
||||
_handleKeydown(event: KeyboardEvent): void {
|
||||
if (event.keyCode === ENTER || event.keyCode === SPACE) {
|
||||
this.select();
|
||||
}
|
||||
}
|
||||
|
||||
_getHostElement(): HTMLElement {
|
||||
return this._element.nativeElement;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
animate,
|
||||
AnimationEntryMetadata,
|
||||
state,
|
||||
style,
|
||||
transition,
|
||||
trigger,
|
||||
} from '@angular/core';
|
||||
|
||||
/**
|
||||
* The following are all the animations for the md-select component, with each
|
||||
* const containing the metadata for one animation.
|
||||
*
|
||||
* The values below match the implementation of the Material 1 md-select animation.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This animation shrinks the placeholder text to 75% of its normal size and translates
|
||||
* it to either the top left corner (ltr) or top right corner (rtl) of the trigger,
|
||||
* depending on the text direction of the application.
|
||||
*/
|
||||
export const transformPlaceholder: AnimationEntryMetadata = trigger('transformPlaceholder', [
|
||||
state('normal', style({
|
||||
transform: `translate3d(0, 0, 0) scale(1)`
|
||||
})),
|
||||
state('floating-ltr', style({
|
||||
transform: `translate3d(-2px, -22px, 0) scale(0.75)`
|
||||
})),
|
||||
state('floating-rtl', style({
|
||||
transform: `translate3d(2px, -22px, 0) scale(0.75)`
|
||||
})),
|
||||
transition('* => *', animate(`400ms cubic-bezier(0.25, 0.8, 0.25, 1)`))
|
||||
]);
|
||||
|
||||
/**
|
||||
* This animation transforms the select's overlay panel on and off the page.
|
||||
*
|
||||
* When the panel is attached to the DOM, it expands its width 32px, scales it up to
|
||||
* 100% on the Y axis, fades in its border, and translates slightly up and to the
|
||||
* side to ensure the option text correctly overlaps the trigger text.
|
||||
*
|
||||
* When the panel is removed from the DOM, it simply fades out linearly.
|
||||
*/
|
||||
export const transformPanel: AnimationEntryMetadata = trigger('transformPanel', [
|
||||
state('showing-ltr', style({
|
||||
opacity: 1,
|
||||
width: 'calc(100% + 32px)',
|
||||
transform: `translate3d(-16px, -9px, 0) scaleY(1)`
|
||||
})),
|
||||
state('showing-rtl', style({
|
||||
opacity: 1,
|
||||
width: 'calc(100% + 32px)',
|
||||
transform: `translate3d(16px, -9px, 0) scaleY(1)`
|
||||
})),
|
||||
transition('void => *', [
|
||||
style({
|
||||
opacity: 0,
|
||||
width: '100%',
|
||||
transform: `translate3d(0, 0, 0) scaleY(0)`
|
||||
}),
|
||||
animate(`150ms cubic-bezier(0.55, 0, 0.55, 0.2)`)
|
||||
]),
|
||||
transition('* => void', [
|
||||
animate('250ms linear', style({opacity: 0}))
|
||||
])
|
||||
]);
|
||||
|
||||
/**
|
||||
* This animation fades in the background color and text content of the
|
||||
* select's options. It is time delayed to occur 100ms after the overlay
|
||||
* panel has transformed in.
|
||||
*/
|
||||
export const fadeInContent: AnimationEntryMetadata = trigger('fadeInContent', [
|
||||
state('showing', style({opacity: 1})),
|
||||
transition('void => showing', [
|
||||
style({opacity: 0}),
|
||||
animate(`150ms 100ms cubic-bezier(0.55, 0, 0.55, 0.2)`)
|
||||
])
|
||||
]);
|
||||
@@ -1 +1,15 @@
|
||||
I'm a select!
|
||||
<div class="md-select-trigger" overlay-origin (click)="toggle()" #origin="overlayOrigin" #trigger>
|
||||
<span class="md-select-placeholder" [@transformPlaceholder]="_getPlaceholderState()"> {{ placeholder }} </span>
|
||||
<span class="md-select-value" *ngIf="selected"> {{ selected?.viewValue }} </span>
|
||||
<span class="md-select-arrow"></span>
|
||||
</div>
|
||||
|
||||
<template connected-overlay [origin]="origin" [open]="panelOpen" hasBackdrop (backdropClick)="close()"
|
||||
backdropClass="md-overlay-transparent-backdrop" [positions]="_positions" [width]="_getWidth()">
|
||||
<div class="md-select-panel" [@transformPanel]="_getPanelState()" (@transformPanel.done)="_onPanelDone()"
|
||||
(keydown)="_keyManager.onKeydown($event)">
|
||||
<div class="md-select-content" [@fadeInContent]="'showing'">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
@import '../core/style/menu-common';
|
||||
|
||||
$md-select-trigger-height: 30px !default;
|
||||
$md-select-trigger-min-width: 112px !default;
|
||||
$md-select-arrow-size: 5px !default;
|
||||
|
||||
md-select {
|
||||
display: inline-block;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.md-select-trigger {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: $md-select-trigger-height;
|
||||
min-width: $md-select-trigger-min-width;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.md-select-placeholder {
|
||||
padding: 0 2px;
|
||||
transform-origin: left top;
|
||||
|
||||
[dir='rtl'] & {
|
||||
transform-origin: right top;
|
||||
}
|
||||
}
|
||||
|
||||
.md-select-value {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.md-select-arrow {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: $md-select-arrow-size solid transparent;
|
||||
border-right: $md-select-arrow-size solid transparent;
|
||||
border-top: $md-select-arrow-size solid;
|
||||
}
|
||||
|
||||
.md-select-panel {
|
||||
@include md-menu-base();
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
md-option {
|
||||
@include md-menu-item-base();
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.md-option-ripple {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
@@ -1,23 +1,365 @@
|
||||
import {TestBed, async} from '@angular/core/testing';
|
||||
import {Component} from '@angular/core';
|
||||
import {TestBed, async, ComponentFixture} from '@angular/core/testing';
|
||||
import {By} from '@angular/platform-browser';
|
||||
import {Component, DebugElement, QueryList, ViewChild, ViewChildren} from '@angular/core';
|
||||
import {MdSelectModule} from './index';
|
||||
|
||||
import {OverlayContainer} from '../core/overlay/overlay-container';
|
||||
import {MdSelect} from './select';
|
||||
import {MdOption} from './option';
|
||||
import {Dir} from '../core/rtl/dir';
|
||||
|
||||
describe('MdSelect', () => {
|
||||
let overlayContainerElement: HTMLElement;
|
||||
let dir: {value: string};
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [MdSelectModule.forRoot()],
|
||||
declarations: [TestSelect],
|
||||
declarations: [BasicSelect],
|
||||
providers: [
|
||||
{provide: OverlayContainer, useFactory: () => {
|
||||
overlayContainerElement = document.createElement('div');
|
||||
return {getContainerElement: () => overlayContainerElement};
|
||||
}},
|
||||
{provide: Dir, useFactory: () => {
|
||||
return dir = { value: 'ltr' };
|
||||
}}
|
||||
]
|
||||
});
|
||||
|
||||
TestBed.compileComponents();
|
||||
}));
|
||||
|
||||
it('should test', () => {
|
||||
// let fixture = TestBed.createComponent(TestSelect);
|
||||
expect(true).toBeTruthy();
|
||||
describe('overlay panel', () => {
|
||||
let fixture: ComponentFixture<BasicSelect>;
|
||||
let trigger: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(BasicSelect);
|
||||
fixture.detectChanges();
|
||||
|
||||
trigger = fixture.debugElement.query(By.css('.md-select-trigger')).nativeElement;
|
||||
});
|
||||
|
||||
it('should open the panel when trigger is clicked', () => {
|
||||
trigger.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.select.panelOpen).toBe(true);
|
||||
expect(overlayContainerElement.textContent).toContain('Steak');
|
||||
expect(overlayContainerElement.textContent).toContain('Pizza');
|
||||
expect(overlayContainerElement.textContent).toContain('Tacos');
|
||||
});
|
||||
|
||||
it('should close the panel when an item is clicked', async(() => {
|
||||
trigger.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const option = overlayContainerElement.querySelector('md-option') as HTMLElement;
|
||||
option.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.whenStable().then(() => {
|
||||
expect(overlayContainerElement.textContent).toEqual('');
|
||||
expect(fixture.componentInstance.select.panelOpen).toBe(false);
|
||||
});
|
||||
}));
|
||||
|
||||
it('should close the panel when a click occurs outside the panel', async(() => {
|
||||
trigger.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const backdrop = overlayContainerElement.querySelector('.md-overlay-backdrop') as HTMLElement;
|
||||
backdrop.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.whenStable().then(() => {
|
||||
expect(overlayContainerElement.textContent).toEqual('');
|
||||
expect(fixture.componentInstance.select.panelOpen).toBe(false);
|
||||
});
|
||||
}));
|
||||
|
||||
it('should set the width of the overlay based on the trigger', () => {
|
||||
trigger.style.width = '200px';
|
||||
trigger.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const pane = overlayContainerElement.children[0] as HTMLElement;
|
||||
expect(pane.style.width).toBe('200px');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('selection logic', () => {
|
||||
let fixture: ComponentFixture<BasicSelect>;
|
||||
let trigger: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(BasicSelect);
|
||||
fixture.detectChanges();
|
||||
|
||||
trigger = fixture.debugElement.query(By.css('.md-select-trigger')).nativeElement;
|
||||
});
|
||||
|
||||
it('should display placeholder if no option is selected', () => {
|
||||
expect(trigger.textContent.trim()).toEqual('Food');
|
||||
});
|
||||
|
||||
it('should focus the first option if no option is selected', async(() => {
|
||||
trigger.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.whenStable().then(() => {
|
||||
expect(fixture.componentInstance.select._keyManager.focusedItemIndex).toEqual(0);
|
||||
});
|
||||
}));
|
||||
|
||||
it('should select an option when it is clicked', () => {
|
||||
trigger.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
let option = overlayContainerElement.querySelector('md-option') as HTMLElement;
|
||||
option.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
trigger.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
option = overlayContainerElement.querySelector('md-option') as HTMLElement;
|
||||
expect(option.classList).toContain('md-selected');
|
||||
expect(fixture.componentInstance.options.first.selected).toBe(true);
|
||||
expect(fixture.componentInstance.select.selected)
|
||||
.toBe(fixture.componentInstance.options.first);
|
||||
});
|
||||
|
||||
it('should deselect other options when one is selected', () => {
|
||||
trigger.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
let options =
|
||||
overlayContainerElement.querySelectorAll('md-option') as NodeListOf<HTMLElement>;
|
||||
|
||||
options[0].click();
|
||||
fixture.detectChanges();
|
||||
|
||||
trigger.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
options =
|
||||
overlayContainerElement.querySelectorAll('md-option') as NodeListOf<HTMLElement>;
|
||||
expect(options[1].classList).not.toContain('md-selected');
|
||||
expect(options[2].classList).not.toContain('md-selected');
|
||||
|
||||
const optionInstances = fixture.componentInstance.options.toArray();
|
||||
expect(optionInstances[1].selected).toBe(false);
|
||||
expect(optionInstances[2].selected).toBe(false);
|
||||
});
|
||||
|
||||
it('should display the selected option in the trigger', () => {
|
||||
trigger.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const option = overlayContainerElement.querySelector('md-option') as HTMLElement;
|
||||
option.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const value = fixture.debugElement.query(By.css('.md-select-value')).nativeElement;
|
||||
const placeholder =
|
||||
fixture.debugElement.query(By.css('.md-select-placeholder')).nativeElement;
|
||||
|
||||
expect(placeholder.textContent).toContain('Food');
|
||||
expect(value.textContent).toContain('Steak');
|
||||
expect(trigger.textContent).toContain('Food');
|
||||
expect(trigger.textContent).toContain('Steak');
|
||||
});
|
||||
|
||||
it('should focus the selected option if an option is selected', async(() => {
|
||||
trigger.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const options =
|
||||
overlayContainerElement.querySelectorAll('md-option') as NodeListOf<HTMLElement>;
|
||||
options[1].click();
|
||||
fixture.detectChanges();
|
||||
|
||||
trigger.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.whenStable().then(() => {
|
||||
expect(fixture.componentInstance.select._keyManager.focusedItemIndex).toEqual(1);
|
||||
});
|
||||
}));
|
||||
|
||||
it('should select an option that was added after initialization', () => {
|
||||
fixture.componentInstance.foods.push({viewValue: 'Pasta'});
|
||||
trigger.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const options =
|
||||
overlayContainerElement.querySelectorAll('md-option') as NodeListOf<HTMLElement>;
|
||||
options[3].click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(trigger.textContent).toContain('Pasta');
|
||||
expect(fixture.componentInstance.select.selected)
|
||||
.toBe(fixture.componentInstance.options.last);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('animations', () => {
|
||||
let fixture: ComponentFixture<BasicSelect>;
|
||||
let trigger: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(BasicSelect);
|
||||
fixture.detectChanges();
|
||||
|
||||
trigger = fixture.debugElement.query(By.css('.md-select-trigger')).nativeElement;
|
||||
});
|
||||
|
||||
it('should float the placeholder when the panel is open', () => {
|
||||
expect(fixture.componentInstance.select._getPlaceholderState()).toEqual('normal');
|
||||
|
||||
trigger.click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.select._getPlaceholderState()).toEqual('floating-ltr');
|
||||
|
||||
const backdrop =
|
||||
overlayContainerElement.querySelector('.md-overlay-backdrop') as HTMLElement;
|
||||
backdrop.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.select._getPlaceholderState()).toEqual('normal');
|
||||
});
|
||||
|
||||
it('should float the placeholder when there is a selection', () => {
|
||||
trigger.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const option = overlayContainerElement.querySelector('md-option') as HTMLElement;
|
||||
option.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.select._getPlaceholderState()).toEqual('floating-ltr');
|
||||
});
|
||||
|
||||
it('should use the floating-rtl state when the dir is rtl', () => {
|
||||
dir.value = 'rtl';
|
||||
|
||||
trigger.click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.select._getPlaceholderState()).toEqual('floating-rtl');
|
||||
});
|
||||
|
||||
it('should use the ltr panel state when the dir is ltr', () => {
|
||||
dir.value = 'ltr';
|
||||
|
||||
trigger.click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.select._getPanelState()).toEqual('showing-ltr');
|
||||
});
|
||||
|
||||
it('should use the rtl panel state when the dir is rtl', () => {
|
||||
dir.value = 'rtl';
|
||||
|
||||
trigger.click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.select._getPanelState()).toEqual('showing-rtl');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
let fixture: ComponentFixture<BasicSelect>;
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(BasicSelect);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('for select', () => {
|
||||
let select: DebugElement;
|
||||
|
||||
beforeEach(() => {
|
||||
select = fixture.debugElement.query(By.css('md-select'));
|
||||
});
|
||||
|
||||
it('should set the role of the select to listbox', () => {
|
||||
expect(select.nativeElement.getAttribute('role')).toEqual('listbox');
|
||||
});
|
||||
|
||||
it('should set the aria label of the select to the placeholder', () => {
|
||||
expect(select.nativeElement.getAttribute('aria-label')).toEqual('Food');
|
||||
});
|
||||
|
||||
it('should set the tabindex of the select to 0', () => {
|
||||
expect(select.nativeElement.getAttribute('tabindex')).toEqual('0');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('for options', () => {
|
||||
let trigger: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
trigger = fixture.debugElement.query(By.css('.md-select-trigger')).nativeElement;
|
||||
trigger.click();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should set the role of md-option to option', () => {
|
||||
const option = overlayContainerElement.querySelector('md-option') as HTMLElement;
|
||||
expect(option.getAttribute('role')).toEqual('option');
|
||||
});
|
||||
|
||||
it('should set aria-selected on each option', () => {
|
||||
const options =
|
||||
overlayContainerElement.querySelectorAll('md-option') as NodeListOf<HTMLElement>;
|
||||
expect(options[0].getAttribute('aria-selected')).toEqual('false');
|
||||
expect(options[1].getAttribute('aria-selected')).toEqual('false');
|
||||
expect(options[2].getAttribute('aria-selected')).toEqual('false');
|
||||
|
||||
options[1].click();
|
||||
fixture.detectChanges();
|
||||
|
||||
trigger.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(options[0].getAttribute('aria-selected')).toEqual('false');
|
||||
expect(options[1].getAttribute('aria-selected')).toEqual('true');
|
||||
expect(options[2].getAttribute('aria-selected')).toEqual('false');
|
||||
});
|
||||
|
||||
it('should set the tabindex of each option to 0', () => {
|
||||
const options =
|
||||
overlayContainerElement.querySelectorAll('md-option') as NodeListOf<HTMLElement>;
|
||||
expect(options[0].getAttribute('tabindex')).toEqual('0');
|
||||
expect(options[1].getAttribute('tabindex')).toEqual('0');
|
||||
expect(options[2].getAttribute('tabindex')).toEqual('0');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@Component({selector: 'test-select', template: ``})
|
||||
class TestSelect {}
|
||||
@Component({
|
||||
selector: 'basic-select',
|
||||
template: `
|
||||
<md-select placeholder="Food">
|
||||
<md-option *ngFor="let food of foods">{{ food.viewValue }}</md-option>
|
||||
</md-select>
|
||||
`
|
||||
})
|
||||
class BasicSelect {
|
||||
foods = [
|
||||
{ viewValue: 'Steak' },
|
||||
{ viewValue: 'Pizza' },
|
||||
{ viewValue: 'Tacos' },
|
||||
];
|
||||
|
||||
@ViewChild(MdSelect) select: MdSelect;
|
||||
@ViewChildren(MdOption) options: QueryList<MdOption>;
|
||||
|
||||
}
|
||||
|
||||
+222
-3
@@ -1,10 +1,229 @@
|
||||
import {Component, ViewEncapsulation} from '@angular/core';
|
||||
import {
|
||||
AfterContentInit,
|
||||
Component,
|
||||
ContentChildren,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnDestroy,
|
||||
Optional,
|
||||
Output,
|
||||
QueryList,
|
||||
Renderer,
|
||||
ViewEncapsulation,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import {MdOption} from './option';
|
||||
import {ENTER, SPACE} from '../core/keyboard/keycodes';
|
||||
import {ListKeyManager} from '../core/a11y/list-key-manager';
|
||||
import {Dir} from '../core/rtl/dir';
|
||||
import {Subscription} from 'rxjs/Subscription';
|
||||
import {transformPlaceholder, transformPanel, fadeInContent} from './select-animations';
|
||||
|
||||
@Component({
|
||||
moduleId: module.id,
|
||||
selector: 'md-select',
|
||||
templateUrl: 'select.html',
|
||||
styleUrls: ['select.css'],
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
host: {
|
||||
'role': 'listbox',
|
||||
'tabindex': '0',
|
||||
'[attr.aria-label]': 'placeholder',
|
||||
'(keydown)': '_openOnActivate($event)'
|
||||
},
|
||||
animations: [
|
||||
transformPlaceholder,
|
||||
transformPanel,
|
||||
fadeInContent
|
||||
],
|
||||
exportAs: 'mdSelect'
|
||||
})
|
||||
export class MdSelect {}
|
||||
export class MdSelect implements AfterContentInit, OnDestroy {
|
||||
/** Whether or not the overlay panel is open. */
|
||||
private _panelOpen = false;
|
||||
|
||||
/** The currently selected option. */
|
||||
private _selected: MdOption;
|
||||
|
||||
/** Subscriptions to option events. */
|
||||
private _subscriptions: Subscription[] = [];
|
||||
|
||||
/** Subscription to changes in the option list. */
|
||||
private _changeSubscription: Subscription;
|
||||
|
||||
/** Subscription to tab events while overlay is focused. */
|
||||
private _tabSubscription: Subscription;
|
||||
|
||||
/** Manages keyboard events for options in the panel. */
|
||||
_keyManager: ListKeyManager;
|
||||
|
||||
/** This position config ensures that the top left corner of the overlay
|
||||
* is aligned with with the top left of the origin (overlapping the trigger
|
||||
* completely). In RTL mode, the top right corners are aligned instead.
|
||||
*/
|
||||
_positions = [{
|
||||
originX: 'start',
|
||||
originY: 'top',
|
||||
overlayX: 'start',
|
||||
overlayY: 'top'
|
||||
}];
|
||||
|
||||
@ViewChild('trigger') trigger: ElementRef;
|
||||
@ContentChildren(MdOption) options: QueryList<MdOption>;
|
||||
|
||||
@Input() placeholder: string;
|
||||
@Output() onOpen = new EventEmitter();
|
||||
@Output() onClose = new EventEmitter();
|
||||
|
||||
constructor(private _element: ElementRef, private _renderer: Renderer,
|
||||
@Optional() private _dir: Dir) {}
|
||||
|
||||
ngAfterContentInit() {
|
||||
this._initKeyManager();
|
||||
this._listenToOptions();
|
||||
|
||||
this._changeSubscription = this.options.changes.subscribe(() => {
|
||||
this._dropSubscriptions();
|
||||
this._listenToOptions();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this._dropSubscriptions();
|
||||
this._changeSubscription.unsubscribe();
|
||||
this._tabSubscription.unsubscribe();
|
||||
}
|
||||
|
||||
/** Toggles the overlay panel open or closed. */
|
||||
toggle(): void {
|
||||
this.panelOpen ? this.close() : this.open();
|
||||
}
|
||||
|
||||
/** Opens the overlay panel. */
|
||||
open(): void {
|
||||
this._panelOpen = true;
|
||||
}
|
||||
|
||||
/** Closes the overlay panel. */
|
||||
close(): void {
|
||||
this._panelOpen = false;
|
||||
}
|
||||
|
||||
/** Whether or not the overlay panel is open. */
|
||||
get panelOpen(): boolean {
|
||||
return this._panelOpen;
|
||||
}
|
||||
|
||||
/** The currently selected option. */
|
||||
get selected(): MdOption {
|
||||
return this._selected;
|
||||
}
|
||||
|
||||
_isRtl(): boolean {
|
||||
return this._dir ? this._dir.value === 'rtl' : false;
|
||||
}
|
||||
|
||||
/** The width of the trigger element. This is necessary to match
|
||||
* the overlay width to the trigger width.
|
||||
*/
|
||||
_getWidth(): number {
|
||||
return this.trigger.nativeElement.getBoundingClientRect().width;
|
||||
}
|
||||
|
||||
/** The animation state of the placeholder. */
|
||||
_getPlaceholderState(): string {
|
||||
if (this.panelOpen || this.selected) {
|
||||
return this._isRtl() ? 'floating-rtl' : 'floating-ltr';
|
||||
} else {
|
||||
return 'normal';
|
||||
}
|
||||
}
|
||||
|
||||
/** The animation state of the overlay panel. */
|
||||
_getPanelState(): string {
|
||||
return this._isRtl() ? 'showing-rtl' : 'showing-ltr';
|
||||
}
|
||||
|
||||
/** Ensures the panel opens if activated by the keyboard. */
|
||||
_openOnActivate(event: KeyboardEvent): void {
|
||||
if (event.keyCode === ENTER || event.keyCode === SPACE) {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
/** Emits an event and sets focus when overlay panel is finished animating. */
|
||||
_onPanelDone(): void {
|
||||
if (this.panelOpen) {
|
||||
this._focusCorrectOption();
|
||||
this.onOpen.emit();
|
||||
} else {
|
||||
this._focusHost();
|
||||
this.onClose.emit();
|
||||
}
|
||||
}
|
||||
|
||||
/** Sets up a key manager to listen to keyboard events on the overlay panel. */
|
||||
private _initKeyManager() {
|
||||
this._keyManager = new ListKeyManager(this.options);
|
||||
this._tabSubscription = this._keyManager.tabOut.subscribe(() => {
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
/** Listens to selection events on each option. */
|
||||
private _listenToOptions(): void {
|
||||
this.options.forEach((option: MdOption) => {
|
||||
const sub = option.onSelect.subscribe(() => this._onSelect(option));
|
||||
this._subscriptions.push(sub);
|
||||
});
|
||||
}
|
||||
|
||||
/** Unsubscribes from all option subscriptions. */
|
||||
private _dropSubscriptions(): void {
|
||||
this._subscriptions.forEach((sub: Subscription) => sub.unsubscribe());
|
||||
this._subscriptions = [];
|
||||
}
|
||||
|
||||
/** When a new option is selected, deselects the others and closes the panel. */
|
||||
private _onSelect(option: MdOption): void {
|
||||
this._selected = option;
|
||||
this._updateOptions();
|
||||
this.close();
|
||||
}
|
||||
|
||||
/** Deselect each option that doesn't match the current selection. */
|
||||
private _updateOptions(): void {
|
||||
this.options.forEach((option: MdOption) => {
|
||||
if (option !== this.selected) {
|
||||
option.deselect();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Focuses the selected item. If no option is selected, it will focus
|
||||
* the first item instead.
|
||||
*/
|
||||
private _focusCorrectOption(): void {
|
||||
if (this.selected) {
|
||||
this._keyManager.focusedItemIndex = this._getOptionIndex(this.selected);
|
||||
this.selected.focus();
|
||||
} else {
|
||||
this._keyManager.focusedItemIndex = 0;
|
||||
this.options.first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/** Focuses the host element when the panel closes. */
|
||||
private _focusHost(): void {
|
||||
this._renderer.invokeElementMethod(this._element.nativeElement, 'focus');
|
||||
}
|
||||
|
||||
/** Gets the index of the provided option in the option list. */
|
||||
private _getOptionIndex(option: MdOption): number {
|
||||
return this.options.reduce((result: number, current: MdOption, index: number) => {
|
||||
return result === undefined ? (option === current ? index : undefined) : result;
|
||||
}, undefined);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user