diff --git a/src/lib/core/theming/_palette.scss b/src/lib/core/theming/_palette.scss index 4ac830a085bd..6785050550bd 100644 --- a/src/lib/core/theming/_palette.scss +++ b/src/lib/core/theming/_palette.scss @@ -660,6 +660,7 @@ $mat-light-theme-background: ( selected-disabled-button: map_get($mat-grey, 400), disabled-button-toggle: map_get($mat-grey, 200), unselected-chip: map_get($mat-grey, 300), + disabled-list-option: map_get($mat-grey, 200), ); // Background palette for dark themes. @@ -675,8 +676,9 @@ $mat-dark-theme-background: ( focused-button: $white-6-opacity, selected-button: map_get($mat-grey, 900), selected-disabled-button: map_get($mat-grey, 800), - disabled-button-toggle: map_get($mat-grey, 1000), + disabled-button-toggle: black, unselected-chip: map_get($mat-grey, 700), + disabled-list-option: black, ); // Foreground palette for light themes. diff --git a/src/lib/list/_list-theme.scss b/src/lib/list/_list-theme.scss index 2355a23d5c80..9ff7714ddc03 100644 --- a/src/lib/list/_list-theme.scss +++ b/src/lib/list/_list-theme.scss @@ -8,16 +8,24 @@ $background: map-get($theme, background); $foreground: map-get($theme, foreground); - .mat-list, .mat-nav-list { + .mat-list, .mat-nav-list, .mat-selection-list { .mat-list-item { color: mat-color($foreground, text); } + .mat-list-option { + color: mat-color($foreground, text); + } + .mat-subheader { color: mat-color($foreground, secondary-text); } } + .mat-list-item-disabled { + background-color: mat-color($background, disabled-list-option); + } + .mat-divider { border-top-color: mat-color($foreground, divider); } @@ -29,6 +37,14 @@ background: mat-color($background, 'hover'); } } + + .mat-list-option { + outline: none; + + &:hover, &.mat-list-item-focus { + background: mat-color($background, 'hover'); + } + } } @mixin mat-list-typography($config) { @@ -38,13 +54,22 @@ font-family: $font-family; } + .mat-list-option { + font-family: $font-family; + } + // Default list - .mat-list, .mat-nav-list { + .mat-list, .mat-nav-list, .mat-selection-list { .mat-list-item { font-size: mat-font-size($config, subheading-2); @include mat-line-base(mat-font-size($config, body-1)); } + .mat-list-option { + font-size: mat-font-size($config, subheading-2); + @include mat-line-base(mat-font-size($config, body-1)); + } + .mat-subheader { font-family: mat-font-family($config, body-2); font-size: mat-font-size($config, body-2); @@ -53,12 +78,17 @@ } // Dense list - .mat-list[dense], .mat-nav-list[dense] { + .mat-list[dense], .mat-nav-list[dense], .mat-selection-list[dense] { .mat-list-item { font-size: mat-font-size($config, caption); @include mat-line-base(mat-font-size($config, caption)); } + .mat-list-option { + font-size: mat-font-size($config, caption); + @include mat-line-base(mat-font-size($config, caption)); + } + .mat-subheader { font-family: $font-family; font-size: mat-font-size($config, caption); diff --git a/src/lib/list/index.ts b/src/lib/list/index.ts index 986b20a1f23a..d5960fe69a19 100644 --- a/src/lib/list/index.ts +++ b/src/lib/list/index.ts @@ -7,7 +7,8 @@ */ import {NgModule} from '@angular/core'; -import {MdLineModule, MdRippleModule, MdCommonModule} from '../core'; +import {MdLineModule, MdRippleModule, MdCommonModule, MdSelectionModule} from '../core'; +import {CommonModule} from '@angular/common'; import { MdList, MdListItem, @@ -17,12 +18,13 @@ import { MdListCssMatStyler, MdNavListCssMatStyler, MdDividerCssMatStyler, - MdListSubheaderCssMatStyler, + MdListSubheaderCssMatStyler } from './list'; +import {MdSelectionList, MdListOption} from './selection-list'; @NgModule({ - imports: [MdLineModule, MdRippleModule, MdCommonModule], + imports: [MdLineModule, MdRippleModule, MdCommonModule, MdSelectionModule, CommonModule], exports: [ MdList, MdListItem, @@ -35,6 +37,9 @@ import { MdNavListCssMatStyler, MdDividerCssMatStyler, MdListSubheaderCssMatStyler, + MdSelectionModule, + MdSelectionList, + MdListOption ], declarations: [ MdList, @@ -46,9 +51,12 @@ import { MdNavListCssMatStyler, MdDividerCssMatStyler, MdListSubheaderCssMatStyler, + MdSelectionList, + MdListOption ], }) export class MdListModule {} export * from './list'; +export * from './selection-list'; diff --git a/src/lib/list/list-option.html b/src/lib/list/list-option.html new file mode 100644 index 000000000000..638a472d7562 --- /dev/null +++ b/src/lib/list/list-option.html @@ -0,0 +1,10 @@ +
+
+
+ + +
+ +
diff --git a/src/lib/list/list.scss b/src/lib/list/list.scss index 7f91e6596895..850275c6dff5 100644 --- a/src/lib/list/list.scss +++ b/src/lib/list/list.scss @@ -43,6 +43,14 @@ $mat-dense-list-icon-size: 20px; position: relative; } + .mat-list-item-content-reverse { + display: flex; + align-items: center; + padding: 0 $mat-list-side-padding; + flex-direction: row-reverse; + justify-content: space-around; + } + .mat-list-item-ripple { position: absolute; left: 0; @@ -128,7 +136,7 @@ $mat-dense-list-icon-size: 20px; } } -.mat-list, .mat-nav-list { +.mat-list, .mat-nav-list, .mat-selection-list { padding-top: $mat-list-top-padding; display: block; @@ -145,10 +153,21 @@ $mat-dense-list-icon-size: 20px; $mat-list-icon-size ); } + + .mat-list-option { + @include mat-list-item-base( + $mat-list-base-height, + $mat-list-avatar-height, + $mat-list-two-line-height, + $mat-list-three-line-height, + $mat-list-icon-size + ); + } } -.mat-list[dense], .mat-nav-list[dense] { +.mat-list[dense], .mat-nav-list[dense], .mat-selection-list[dense] { + padding-top: $mat-dense-top-padding; display: block; @@ -165,6 +184,16 @@ $mat-dense-list-icon-size: 20px; $mat-dense-list-icon-size ); } + + .mat-list-option { + @include mat-list-item-base( + $mat-dense-base-height, + $mat-dense-avatar-height, + $mat-dense-two-line-height, + $mat-dense-three-line-height, + $mat-dense-list-icon-size + ); + } } .mat-divider { diff --git a/src/lib/list/list.ts b/src/lib/list/list.ts index 95ddd64788a8..a78156d71e34 100644 --- a/src/lib/list/list.ts +++ b/src/lib/list/list.ts @@ -32,6 +32,7 @@ export const _MdListMixinBase = mixinDisableRipple(MdListBase); export class MdListItemBase {} export const _MdListItemMixinBase = mixinDisableRipple(MdListItemBase); + @Directive({ selector: 'md-divider, mat-divider', host: { diff --git a/src/lib/list/selection-list.spec.ts b/src/lib/list/selection-list.spec.ts new file mode 100644 index 000000000000..926913c6e3c7 --- /dev/null +++ b/src/lib/list/selection-list.spec.ts @@ -0,0 +1,373 @@ +import {async, TestBed, ComponentFixture, inject} from '@angular/core/testing'; +import {Component, DebugElement} from '@angular/core'; +import {By} from '@angular/platform-browser'; +import {MdSelectionList, MdListOption, MdListModule} from './index'; +import {createKeyboardEvent} from '@angular/cdk/testing'; +import {UP_ARROW, DOWN_ARROW, SPACE} from '../core/keyboard/keycodes'; +import {Platform} from '../core/platform/index'; + + +describe('MdSelectionList', () => { + describe('with list option', () => { + let fixture: ComponentFixture; + let listOption: DebugElement[]; + let listItemEl: DebugElement; + let selectionList: DebugElement; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [MdListModule], + declarations: [ + SelectionListWithListOptions, + SelectionListWithCheckboxPositionAfter, + SelectionListWithListDisabled, + SelectionListWithOnlyOneOption + ], + }); + + TestBed.compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(SelectionListWithListOptions); + listOption = fixture.debugElement.queryAll(By.directive(MdListOption)); + listItemEl = fixture.debugElement.query(By.css('.mat-list-item')); + selectionList = fixture.debugElement.query(By.directive(MdSelectionList)); + fixture.detectChanges(); + })); + + it('should add and remove focus class on focus/blur', () => { + expect(listItemEl.nativeElement.classList).not.toContain('mat-list-item-focus'); + + listOption[0].componentInstance._handleFocus(); + fixture.detectChanges(); + expect(listItemEl.nativeElement.className).toContain('mat-list-item-focus'); + + listOption[0].componentInstance._handleBlur(); + fixture.detectChanges(); + expect(listItemEl.nativeElement.className).not.toContain('mat-list-item-focus'); + }); + + it('should be able to dispatch one selected item', () => { + let testListItem = listOption[2].injector.get(MdListOption); + let selectList = selectionList.injector.get(MdSelectionList).selectedOptions; + + expect(selectList.selected.length).toBe(0); + expect(listOption[2].nativeElement.getAttribute('aria-selected')).toBe('false'); + + testListItem.toggle(); + fixture.detectChanges(); + + expect(listOption[2].nativeElement.getAttribute('aria-selected')).toBe('true'); + expect(listOption[2].nativeElement.getAttribute('aria-disabled')).toBe('false'); + expect(selectList.selected.length).toBe(1); + }); + + it('should be able to dispatch multiple selected items', () => { + let testListItem = listOption[2].injector.get(MdListOption); + let testListItem2 = listOption[1].injector.get(MdListOption); + let selectList = selectionList.injector.get(MdSelectionList).selectedOptions; + + expect(selectList.selected.length).toBe(0); + expect(listOption[2].nativeElement.getAttribute('aria-selected')).toBe('false'); + expect(listOption[1].nativeElement.getAttribute('aria-selected')).toBe('false'); + + testListItem.toggle(); + fixture.detectChanges(); + + testListItem2.toggle(); + fixture.detectChanges(); + + expect(selectList.selected.length).toBe(2); + expect(listOption[2].nativeElement.getAttribute('aria-selected')).toBe('true'); + expect(listOption[1].nativeElement.getAttribute('aria-selected')).toBe('true'); + expect(listOption[1].nativeElement.getAttribute('aria-disabled')).toBe('false'); + expect(listOption[2].nativeElement.getAttribute('aria-disabled')).toBe('false'); + }); + + it('should be able to deselect an option', () => { + let testListItem = listOption[2].injector.get(MdListOption); + let selectList = selectionList.injector.get(MdSelectionList).selectedOptions; + + expect(selectList.selected.length).toBe(0); + + testListItem.toggle(); + fixture.detectChanges(); + + expect(selectList.selected.length).toBe(1); + + testListItem.toggle(); + fixture.detectChanges(); + + expect(selectList.selected.length).toBe(0); + }); + + it('should not allow selection of disabled items', () => { + let testListItem = listOption[0].injector.get(MdListOption); + let selectList = selectionList.injector.get(MdSelectionList).selectedOptions; + + expect(selectList.selected.length).toBe(0); + expect(listOption[0].nativeElement.getAttribute('aria-disabled')).toBe('true'); + + testListItem._handleClick(); + fixture.detectChanges(); + + expect(selectList.selected.length).toBe(0); + }); + + it('should be able to un-disable disabled items', () => { + let testListItem = listOption[0].injector.get(MdListOption); + + expect(listOption[0].nativeElement.getAttribute('aria-disabled')).toBe('true'); + + testListItem.disabled = false; + fixture.detectChanges(); + + expect(listOption[0].nativeElement.getAttribute('aria-disabled')).toBe('false'); + }); + + it('should be able to use keyboard select with SPACE', () => { + let testListItem = listOption[1].nativeElement as HTMLElement; + let SPACE_EVENT: KeyboardEvent = + createKeyboardEvent('keydown', SPACE, testListItem); + let selectList = selectionList.injector.get(MdSelectionList).selectedOptions; + let options = selectionList.componentInstance.options; + let array = options.toArray(); + let focusItem = array[1]; + expect(selectList.selected.length).toBe(0); + + focusItem.focus(); + selectionList.componentInstance._keydown(SPACE_EVENT); + + fixture.detectChanges(); + + expect(selectList.selected.length).toBe(1); + }); + + it('should focus previous item when press UP ARROW', () => { + let testListItem = listOption[2].nativeElement as HTMLElement; + let UP_EVENT: KeyboardEvent = + createKeyboardEvent('keydown', UP_ARROW, testListItem); + let options = selectionList.componentInstance.options; + let array = options.toArray(); + let focusItem = array[2]; + let manager = selectionList.componentInstance._keyManager; + + focusItem.focus(); + expect(manager.activeItemIndex).toEqual(2); + + selectionList.componentInstance._keydown(UP_EVENT); + + fixture.detectChanges(); + + expect(manager.activeItemIndex).toEqual(1); + }); + + it('should focus next item when press DOWN ARROW', () => { + let testListItem = listOption[2].nativeElement as HTMLElement; + let DOWN_EVENT: KeyboardEvent = + createKeyboardEvent('keydown', DOWN_ARROW, testListItem); + let options = selectionList.componentInstance.options; + let array = options.toArray(); + let focusItem = array[2]; + let manager = selectionList.componentInstance._keyManager; + + focusItem.focus(); + expect(manager.activeItemIndex).toEqual(2); + + selectionList.componentInstance._keydown(DOWN_EVENT); + + fixture.detectChanges(); + + expect(manager.activeItemIndex).toEqual(3); + }); + }); + + describe('with single option', () => { + let fixture: ComponentFixture; + let listOption: DebugElement; + let listItemEl: DebugElement; + let selectionList: DebugElement; + let platform: Platform; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [MdListModule], + declarations: [ + SelectionListWithListOptions, + SelectionListWithCheckboxPositionAfter, + SelectionListWithListDisabled, + SelectionListWithOnlyOneOption + ], + }); + + TestBed.compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(SelectionListWithOnlyOneOption); + listOption = fixture.debugElement.query(By.directive(MdListOption)); + listItemEl = fixture.debugElement.query(By.css('.mat-list-item')); + selectionList = fixture.debugElement.query(By.directive(MdSelectionList)); + fixture.detectChanges(); + })); + + beforeEach(inject([Platform], (p: Platform) => { + platform = p; + })); + + it('should be focused when focus on nativeElements', () => { + listOption.nativeElement.focus(); + fixture.detectChanges(); + + expect(listItemEl.nativeElement).toBe(document.activeElement); + if (platform.SAFARI || platform.FIREFOX) { + expect(listItemEl.nativeElement.className).toContain('mat-list-item-focus'); + } + + listOption.nativeElement.blur(); + fixture.detectChanges(); + + expect(listItemEl.nativeElement.className).not.toContain('mat-list-item-focus'); + }); + }); + + describe('with list disabled', () => { + let fixture: ComponentFixture; + let listOption: DebugElement[]; + let listItemEl: DebugElement; + let selectionList: DebugElement; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [MdListModule], + declarations: [ + SelectionListWithListOptions, + SelectionListWithCheckboxPositionAfter, + SelectionListWithListDisabled, + SelectionListWithOnlyOneOption + ], + }); + + TestBed.compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(SelectionListWithListDisabled); + listOption = fixture.debugElement.queryAll(By.directive(MdListOption)); + listItemEl = fixture.debugElement.query(By.css('.mat-list-item')); + selectionList = fixture.debugElement.query(By.directive(MdSelectionList)); + fixture.detectChanges(); + })); + + it('should not allow selection on disabled selection-list', () => { + let testListItem = listOption[2].injector.get(MdListOption); + let selectList = selectionList.injector.get(MdSelectionList).selectedOptions; + + expect(selectList.selected.length).toBe(0); + + testListItem._handleClick(); + fixture.detectChanges(); + + expect(selectList.selected.length).toBe(0); + }); + }); + + describe('with checkbox position after', () => { + let fixture: ComponentFixture; + let listOption: DebugElement[]; + let listItemEl: DebugElement; + let selectionList: DebugElement; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [MdListModule], + declarations: [ + SelectionListWithListOptions, + SelectionListWithCheckboxPositionAfter, + SelectionListWithListDisabled, + SelectionListWithOnlyOneOption + ], + }); + + TestBed.compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(SelectionListWithCheckboxPositionAfter); + listOption = fixture.debugElement.queryAll(By.directive(MdListOption)); + listItemEl = fixture.debugElement.query(By.css('.mat-list-item')); + selectionList = fixture.debugElement.query(By.directive(MdSelectionList)); + fixture.detectChanges(); + })); + + it('should be able to customize checkbox position', () => { + let listItemContent = fixture.debugElement.query(By.css('.mat-list-item-content')); + expect(listItemContent.nativeElement.classList).toContain('mat-list-item-content-reverse'); + }); + }); +}); + + +@Component({template: ` + + + Inbox (disabled selection-option) + + + Starred + + + Sent Mail + + + Drafts + + `}) +class SelectionListWithListOptions { +} + +@Component({template: ` + + + Inbox (disabled selection-option) + + + Starred + + + Sent Mail + + + Drafts + + `}) +class SelectionListWithCheckboxPositionAfter { +} + +@Component({template: ` + + + Inbox (disabled selection-option) + + + Starred + + + Sent Mail + + + Drafts + + `}) +class SelectionListWithListDisabled { +} + +@Component({template: ` + + + Inbox + + `}) +class SelectionListWithOnlyOneOption { +} diff --git a/src/lib/list/selection-list.ts b/src/lib/list/selection-list.ts new file mode 100644 index 000000000000..1eda2a70c32c --- /dev/null +++ b/src/lib/list/selection-list.ts @@ -0,0 +1,321 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + AfterContentInit, + Component, + ContentChildren, + ElementRef, + Input, + QueryList, + ViewEncapsulation, + Optional, + Renderer2, + EventEmitter, + Output, + ChangeDetectionStrategy, + ChangeDetectorRef, + OnDestroy, + forwardRef, + Inject, +} from '@angular/core'; +import {coerceBooleanProperty, SelectionModel, MdLine, MdLineSetter} from '../core'; +import {FocusKeyManager} from '../core/a11y/focus-key-manager'; +import {Subscription} from 'rxjs/Subscription'; +import {SPACE} from '../core/keyboard/keycodes'; +import {FocusableOption} from '../core/a11y/focus-key-manager'; +import {CanDisable, mixinDisabled} from '../core/common-behaviors/disabled'; +import {RxChain, switchMap, startWith} from '../core/rxjs/index'; +import {merge} from 'rxjs/observable/merge'; + +export class MdSelectionListBase {} +export const _MdSelectionListMixinBase = mixinDisabled(MdSelectionListBase); + + +export interface MdSelectionListOptionEvent { + option: MdListOption; +} + +const FOCUSED_STYLE: string = 'mat-list-item-focus'; + +/** + * Component for list-options of selection-list. Each list-option can automatically + * generate a checkbox and can put current item into the selectionModel of selection-list + * if the current item is checked. + */ +@Component({ + moduleId: module.id, + selector: 'md-list-option, mat-list-option', + host: { + 'role': 'option', + 'class': 'mat-list-item mat-list-option', + '(focus)': '_handleFocus()', + '(blur)': '_handleBlur()', + '(click)': '_handleClick()', + 'tabindex': '-1', + '[attr.aria-selected]': 'selected.toString()', + '[attr.aria-disabled]': 'disabled.toString()', + }, + templateUrl: 'list-option.html', + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class MdListOption implements AfterContentInit, OnDestroy, FocusableOption { + private _lineSetter: MdLineSetter; + private _disableRipple: boolean = false; + private _selected: boolean = false; + /** Whether the checkbox is disabled. */ + private _disabled: boolean = false; + private _value: any; + + /** Whether the option has focus. */ + _hasFocus: boolean = false; + + /** + * Whether the ripple effect on click should be disabled. This applies only to list items that are + * part of a selection list. The value of `disableRipple` on the `md-selection-list` overrides + * this flag + */ + @Input() + get disableRipple() { return this._disableRipple; } + set disableRipple(value: boolean) { this._disableRipple = coerceBooleanProperty(value); } + + @ContentChildren(MdLine) _lines: QueryList; + + /** Whether the label should appear before or after the checkbox. Defaults to 'after' */ + @Input() checkboxPosition: 'before' | 'after' = 'after'; + + /** Whether the option is disabled. */ + @Input() + get disabled() { return (this.selectionList && this.selectionList.disabled) || this._disabled; } + set disabled(value: any) { this._disabled = coerceBooleanProperty(value); } + + @Input() + get value() { return this._value; } + set value( val: any) { this._value = coerceBooleanProperty(val); } + + @Input() + get selected() { return this._selected; } + set selected( val: boolean) { this._selected = coerceBooleanProperty(val); } + + /** Emitted when the option is focused. */ + onFocus = new EventEmitter(); + + /** Emitted when the option is selected. */ + @Output() selectChange = new EventEmitter(); + + /** Emitted when the option is deselected. */ + @Output() deselected = new EventEmitter(); + + /** Emitted when the option is destroyed. */ + @Output() destroyed = new EventEmitter(); + + constructor(private _renderer: Renderer2, + private _element: ElementRef, + private _changeDetector: ChangeDetectorRef, + @Optional() @Inject(forwardRef(() => MdSelectionList)) public selectionList: MdSelectionList) { } + + + ngAfterContentInit() { + this._lineSetter = new MdLineSetter(this._lines, this._renderer, this._element); + + if (this.selectionList.disabled) { + this.disabled = true; + } + } + + ngOnDestroy(): void { + this.destroyed.emit({option: this}); + } + + toggle(): void { + this.selected = !this.selected; + this.selectionList.selectedOptions.toggle(this); + this._changeDetector.markForCheck(); + } + + /** Allows for programmatic focusing of the option. */ + focus(): void { + this._element.nativeElement.focus(); + this.onFocus.emit({option: this}); + } + + /** Whether this list item should show a ripple effect when clicked. */ + isRippleEnabled() { + return !this.disableRipple && !this.selectionList.disableRipple; + } + + _handleClick() { + if (!this.disabled) { + this.toggle(); + } + } + + _handleFocus() { + this._hasFocus = true; + this._renderer.addClass(this._element.nativeElement, FOCUSED_STYLE); + } + + _handleBlur() { + this._renderer.removeClass(this._element.nativeElement, FOCUSED_STYLE); + } + + /** Retrieves the DOM element of the component host. */ + _getHostElement(): HTMLElement { + return this._element.nativeElement; + } +} + + +@Component({ + moduleId: module.id, + selector: 'md-selection-list, mat-selection-list', + inputs: ['disabled'], + host: { + 'role': 'listbox', + '[attr.tabindex]': '_tabIndex', + 'class': 'mat-selection-list', + '(focus)': 'focus()', + '(keydown)': '_keydown($event)', + '[attr.aria-disabled]': 'disabled.toString()'}, + template: '', + styleUrls: ['list.css'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class MdSelectionList extends _MdSelectionListMixinBase + implements FocusableOption, CanDisable, AfterContentInit, OnDestroy { + private _disableRipple: boolean = false; + + /** Tab index for the selection-list. */ + _tabIndex = 0; + + /** Subscription to all list options' onFocus events */ + private _optionFocusSubscription: Subscription; + + /** Subscription to all list options' destroy events */ + private _optionDestroyStream: Subscription; + + /** The FocusKeyManager which handles focus. */ + _keyManager: FocusKeyManager; + + /** The option components contained within this selection-list. */ + @ContentChildren(MdListOption) options; + + /** options which are selected. */ + selectedOptions: SelectionModel = new SelectionModel(true); + + /** + * Whether the ripple effect should be disabled on the list-items or not. + * This flag only has an effect for `mat-selection-list` components. + */ + @Input() + get disableRipple() { return this._disableRipple; } + set disableRipple(value: boolean) { this._disableRipple = coerceBooleanProperty(value); } + + constructor(private _element: ElementRef) { + super(); + } + + ngAfterContentInit(): void { + this._keyManager = new FocusKeyManager(this.options).withWrap(); + + if (this.disabled) { + this._tabIndex = -1; + } + + this._optionFocusSubscription = this._onFocusSubscription(); + this._optionDestroyStream = this._onDestroySubscription(); + } + + ngOnDestroy(): void { + if (this._optionDestroyStream) { + this._optionDestroyStream.unsubscribe(); + } + + if (this._optionFocusSubscription) { + this._optionFocusSubscription.unsubscribe(); + } + } + + focus() { + this._element.nativeElement.focus(); + } + + /** + * Map all the options' destroy event subscriptions and merge them into one stream. + */ + private _onDestroySubscription(): Subscription { + return RxChain.from(this.options.changes) + .call(startWith, this.options) + .call(switchMap, (options: MdListOption[]) => { + return merge(...options.map(option => option.destroyed)); + }).subscribe((e: MdSelectionListOptionEvent) => { + let optionIndex: number = this.options.toArray().indexOf(e.option); + if (e.option._hasFocus) { + // Check whether the option is the last item + if (optionIndex < this.options.length - 1) { + this._keyManager.setActiveItem(optionIndex); + } else if (optionIndex - 1 >= 0) { + this._keyManager.setActiveItem(optionIndex - 1); + } + } + e.option.destroyed.unsubscribe(); + }); + } + + /** + * Map all the options' onFocus event subscriptions and merge them into one stream. + */ + private _onFocusSubscription(): Subscription { + return RxChain.from(this.options.changes) + .call(startWith, this.options) + .call(switchMap, (options: MdListOption[]) => { + return merge(...options.map(option => option.onFocus)); + }).subscribe((e: MdSelectionListOptionEvent) => { + let optionIndex: number = this.options.toArray().indexOf(e.option); + this._keyManager.updateActiveItemIndex(optionIndex); + }); + } + + /** Passes relevant key presses to our key manager. */ + _keydown(event: KeyboardEvent) { + switch (event.keyCode) { + case SPACE: + this._toggleSelectOnFocusedOption(); + // Always prevent space from scrolling the page since the list has focus + event.preventDefault(); + break; + default: + this._keyManager.onKeydown(event); + } + } + + /** Toggles the selected state of the currently focused option. */ + private _toggleSelectOnFocusedOption(): void { + let focusedIndex = this._keyManager.activeItemIndex; + + if (focusedIndex != null && this._isValidIndex(focusedIndex)) { + let focusedOption: MdListOption = this.options.toArray()[focusedIndex]; + + if (focusedOption) { + focusedOption.toggle(); + } + } + } + + /** + * Utility to ensure all indexes are valid. + * + * @param index The index to be checked. + * @returns True if the index is valid for our list of options. + */ + private _isValidIndex(index: number): boolean { + return index >= 0 && index < this.options.length; + } +}