diff --git a/package-lock.json b/package-lock.json index 304ff5aebfff..5c090d588a6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -531,6 +531,26 @@ "through2": "2.0.3" } }, + "@gulp-sourcemaps/identity-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@gulp-sourcemaps/identity-map/-/identity-map-1.0.1.tgz", + "integrity": "sha1-z6I7xYQPkQTOMqZedNt+epdLvuE=", + "dev": true, + "dependencies": { + "acorn": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.0.3.tgz", + "integrity": "sha1-xGDfCEkUY/AozLguqzcwvwEIez0=", + "dev": true + } + } + }, + "@gulp-sourcemaps/map-sources": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@gulp-sourcemaps/map-sources/-/map-sources-1.0.0.tgz", + "integrity": "sha1-iQrnxdjId/bThIYCFazp1+yUW9o=", + "dev": true + }, "@types/chalk": { "version": "0.4.31", "resolved": "https://registry.npmjs.org/@types/chalk/-/chalk-0.4.31.tgz", @@ -747,6 +767,11 @@ "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=", "dev": true }, + "angular": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/angular/-/angular-1.6.4.tgz", + "integrity": "sha1-A7exXAGggC1+LPWTJA5gQFTcd/s=" + }, "ansi-align": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-1.1.0.tgz", @@ -1096,6 +1121,12 @@ "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", "dev": true }, + "atob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/atob/-/atob-1.1.3.tgz", + "integrity": "sha1-lfE2KbEsOlGl0hWr3OKqnzL4B3M=", + "dev": true + }, "autoprefixer": { "version": "6.7.7", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-6.7.7.tgz", @@ -2251,6 +2282,12 @@ "trim-off-newlines": "1.0.1" } }, + "convert-source-map": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.5.0.tgz", + "integrity": "sha1-ms1whRxtXf3ZPZKC5e35SgP/RrU=", + "dev": true + }, "cookie": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", @@ -2437,6 +2474,20 @@ "uid-safe": "2.1.4" } }, + "css": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css/-/css-2.2.1.tgz", + "integrity": "sha1-c6TIHehdtmTU7mdPfUcIXjstVdw=", + "dev": true, + "dependencies": { + "source-map": { + "version": "0.1.43", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.43.tgz", + "integrity": "sha1-wkvBRspRfBRx9drL4lcbK3+eM0Y=", + "dev": true + } + } + }, "css-color-names": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/css-color-names/-/css-color-names-0.0.3.tgz", @@ -2711,6 +2762,20 @@ "ms": "2.0.0" } }, + "debug-fabulous": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/debug-fabulous/-/debug-fabulous-0.1.0.tgz", + "integrity": "sha1-rQ6gel1RkyT7VYQqjzTuWcf4/2w=", + "dev": true, + "dependencies": { + "object-assign": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.0.tgz", + "integrity": "sha1-ejs9DpgGPUP0wD8uiubNUahog6A=", + "dev": true + } + } + }, "decamelize": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", @@ -2858,6 +2923,12 @@ "fs-exists-sync": "0.1.0" } }, + "detect-newline": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-2.1.0.tgz", + "integrity": "sha1-9B8cEL5LAOh7XxPaaAdZ8sW/0+I=", + "dev": true + }, "detective-amd": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/detective-amd/-/detective-amd-2.4.0.tgz", @@ -8910,6 +8981,20 @@ "vinyl-sourcemaps-apply": "0.2.1" } }, + "gulp-sourcemaps": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/gulp-sourcemaps/-/gulp-sourcemaps-2.6.0.tgz", + "integrity": "sha1-fMzomaijv8oVk6M0jQ+/Qd0/UeU=", + "dev": true, + "dependencies": { + "acorn": { + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-4.0.13.tgz", + "integrity": "sha1-EFSVrlNh1pe9GVyCUZLhrX8lN4c=", + "dev": true + } + } + }, "gulp-transform": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/gulp-transform/-/gulp-transform-2.0.0.tgz", @@ -9603,12 +9688,6 @@ "lower-case": "1.1.4" } }, - "is-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=", - "dev": true - }, "is-my-json-valid": { "version": "2.16.0", "resolved": "https://registry.npmjs.org/is-my-json-valid/-/is-my-json-valid-2.16.0.tgz", @@ -13327,6 +13406,18 @@ "uuid": "3.1.0" } }, + "request-promise": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/request-promise/-/request-promise-4.2.1.tgz", + "integrity": "sha1-fuxWyJMXqCLL/qmbA5zlQ8LhX2c=", + "dev": true + }, + "request-promise-core": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.1.tgz", + "integrity": "sha1-Pu4AssWqgyOc+wTFcA2jb4HNCLY=", + "dev": true + }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -13452,6 +13543,12 @@ "integrity": "sha1-six699nWiBvItuZTM17rywoYh0g=", "dev": true }, + "resolve-url": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", + "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=", + "dev": true + }, "response-time": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/response-time/-/response-time-2.3.2.tgz", @@ -14359,6 +14456,12 @@ "integrity": "sha1-dc449SvwczxafwwRjYEzSiu19BI=", "dev": true }, + "source-map-resolve": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.3.1.tgz", + "integrity": "sha1-YQ9hIqRFuN1RU1oqcbeD38Ekh2E=", + "dev": true + }, "source-map-support": { "version": "0.4.15", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.4.15.tgz", @@ -14368,6 +14471,12 @@ "source-map": "0.5.6" } }, + "source-map-url": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.3.0.tgz", + "integrity": "sha1-fsrxO1e80J2opAxdJp2zN5nUqvk=", + "dev": true + }, "sourcemap-codec": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.3.1.tgz", @@ -14495,6 +14604,12 @@ "readable-stream": "2.3.3" } }, + "stealthy-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", + "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=", + "dev": true + }, "stream-combiner": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", @@ -14638,6 +14753,12 @@ "is-utf8": "0.2.1" } }, + "strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha1-5SEekiQ2n7uB1jOi8ABE3IztrZI=", + "dev": true + }, "strip-eof": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", @@ -15484,6 +15605,12 @@ "integrity": "sha1-YdvC1Ttp/2CRoSoWj9fUMxB+QPE=", "dev": true }, + "travis-after-modes": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/travis-after-modes/-/travis-after-modes-0.0.7.tgz", + "integrity": "sha1-o/PAWNPoaqp79Buwh8dduRRacYw=", + "dev": true + }, "trim-newlines": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz", @@ -15607,7 +15734,8 @@ "tslib": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.7.1.tgz", - "integrity": "sha1-vIAEFkaRkjp5/oN4u+s9ogF1OOw=" + "integrity": "sha1-vIAEFkaRkjp5/oN4u+s9ogF1OOw=", + "dev": true }, "tslint": { "version": "5.5.0", @@ -16027,6 +16155,12 @@ "upper-case": "1.1.3" } }, + "urix": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", + "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=", + "dev": true + }, "url-join": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/url-join/-/url-join-0.0.1.tgz", diff --git a/package.json b/package.json index 77a9c2e020d1..cebdc5e813ff 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ "highlight.js": "^9.11.0", "http-rewrite-middleware": "^0.1.6", "image-diff": "^1.6.3", - "jasmine-core": "^2.6.2", + "jasmine-core": "^2.6.3", "jsonwebtoken": "^7.4.1", "karma": "^1.7.0", "karma-browserstack-launcher": "^1.2.0", diff --git a/src/lib/list/_list-theme.scss b/src/lib/list/_list-theme.scss index 2355a23d5c80..e582e82eb8ba 100644 --- a/src/lib/list/_list-theme.scss +++ b/src/lib/list/_list-theme.scss @@ -8,11 +8,15 @@ $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); } @@ -29,6 +33,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 +50,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 +74,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..6ac9c1fee208 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,14 @@ import { MdListCssMatStyler, MdNavListCssMatStyler, MdDividerCssMatStyler, - MdListSubheaderCssMatStyler, + MdListSubheaderCssMatStyler } from './list'; +import {MdSelectionList} from './selection-list'; +import {MdListOption} from './list-option'; @NgModule({ - imports: [MdLineModule, MdRippleModule, MdCommonModule], + imports: [MdLineModule, MdRippleModule, MdCommonModule, MdSelectionModule, CommonModule], exports: [ MdList, MdListItem, @@ -35,6 +38,9 @@ import { MdNavListCssMatStyler, MdDividerCssMatStyler, MdListSubheaderCssMatStyler, + MdSelectionModule, + MdSelectionList, + MdListOption ], declarations: [ MdList, @@ -46,6 +52,8 @@ import { MdNavListCssMatStyler, MdDividerCssMatStyler, MdListSubheaderCssMatStyler, + MdSelectionList, + MdListOption ], }) export class MdListModule {} diff --git a/src/lib/list/list-option.html b/src/lib/list/list-option.html new file mode 100644 index 000000000000..0489fa056d4b --- /dev/null +++ b/src/lib/list/list-option.html @@ -0,0 +1,10 @@ +
+
+
+ + +
+ +
diff --git a/src/lib/list/list-option.ts b/src/lib/list/list-option.ts new file mode 100644 index 000000000000..6ee0d4259632 --- /dev/null +++ b/src/lib/list/list-option.ts @@ -0,0 +1,154 @@ +/** + * @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, + ContentChild, + ContentChildren, + Directive, + ElementRef, + Input, + Optional, + QueryList, + Renderer2, + ViewEncapsulation, + ChangeDetectionStrategy, + OnDestroy, + EventEmitter, + Output, + ChangeDetectorRef +} from '@angular/core'; +import {coerceBooleanProperty, MdLine, MdLineSetter} from '../core'; +import {Focusable} from '../core/a11y/focus-key-manager'; +import {MdSelectionList} from './selection-list'; + +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)': 'toggle()', + '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, Focusable { + 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 nav list. The value of `disableRipple` on the `md-nav-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 after or before 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() select = new EventEmitter(); + + /** Emitted when the option is deselected. */ + @Output() deselect = new EventEmitter(); + + /** Emitted when the option is destroyed. */ + @Output() destroy = new EventEmitter(); + + constructor(private _renderer: Renderer2, + private _element: ElementRef, + private _changeDetector: ChangeDetectorRef, + @Optional() public selectionList: MdSelectionList,) { } + + + ngAfterContentInit() { + this._lineSetter = new MdLineSetter(this._lines, this._renderer, this._element); + } + + ngOnDestroy(): void { + this.destroy.emit({option: this}); + } + + toggle(): void { + if(this._disabled == false) { + 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; + } + + _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; + } +} diff --git a/src/lib/list/list.scss b/src/lib/list/list.scss index 7f91e6596895..3136db6e4f30 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,20 @@ $mat-dense-list-icon-size: 20px; $mat-list-icon-size ); } + + .mat-list-option { + @include mat-line-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 +183,17 @@ $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 { @@ -188,3 +217,5 @@ $mat-dense-list-icon-size: 20px; } } } + + 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.ts b/src/lib/list/selection-list.ts new file mode 100644 index 000000000000..b0ec663c03cd --- /dev/null +++ b/src/lib/list/selection-list.ts @@ -0,0 +1,229 @@ +/** + * @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, + ContentChild, + ContentChildren, + Directive, + ElementRef, + Input, + Optional, + QueryList, + Renderer2, + ViewEncapsulation, + ChangeDetectionStrategy, + OnDestroy, + EventEmitter, + Output, + ChangeDetectorRef +} from '@angular/core'; +import {coerceBooleanProperty, MdLine, MdLineSetter, SelectionModel} from '../core'; +import {FocusKeyManager} from '../core/a11y/focus-key-manager'; +import {Subscription} from 'rxjs/Subscription'; +import {SPACE} from '../core/keyboard/keycodes'; +import {Focusable} from '../core/a11y/focus-key-manager'; +import {MdListOption} from './list-option'; + +@Component({ + moduleId: module.id, + selector: 'md-selection-list, mat-selection-list', + host: { + 'role': 'listbox', + '[attr.tabindex]': '_tabIndex', + 'class': 'mat-selection-list', + '(focus)': 'focus()', + '(keydown)': 'keydown($event)'}, + template: '', + styleUrls: ['list.css'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class MdSelectionList implements AfterContentInit, OnDestroy { + private _disableRipple: boolean = false; + + private _disabled: boolean = false; + + /** Tab index for the selection-list. */ + _tabIndex = 0; + + /** Track which options we're listening to for focus/destruction. */ + private _subscribed: WeakMap = new WeakMap(); + + /** Subscription to tabbing out from the selection-list. */ + private _tabOutSubscription: Subscription; + + /** Subscription to option changes from the selection-list. */ + private _optionSubscription: Subscription; + + /** Whether or not the option is selectable. */ + protected _selectable: boolean = true; + + /** 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); } + + /** Whether the selection-list is disabled */ + @Input() + get disabled() { return this._disabled; } + set disabled(value: any) { this._disabled = coerceBooleanProperty(value); } + + constructor(private _element: ElementRef) { } + + ngAfterContentInit(): void { + this._keyManager = new FocusKeyManager(this.options).withWrap(); + + // Prevents the selection-list from capturing focus and redirecting + // it back to the first option when the user tabs out. + this._tabOutSubscription = this._keyManager.tabOut.subscribe(() => { + this._tabIndex = -1; + setTimeout(() => this._tabIndex = 0); + }); + + // Go ahead and subscribe all of the initial options + this._subscribeOptions(this.options); + + // When the list changes, re-subscribe + this._optionSubscription = + this.options.changes.subscribe((options: QueryList) => { + this._subscribeOptions(options); + }); + } + + ngOnDestroy(): void { + if (this._tabOutSubscription) { + this._tabOutSubscription.unsubscribe(); + } + + if (this._optionSubscription) { + this._optionSubscription.unsubscribe(); + } + } + + /** + * Whether or not this option is selectable. When a option is not selectable, + * it's selected state is always ignored. + */ + @Input() + get selectable(): boolean { return this._selectable; } + set selectable(value: boolean) { + this._selectable = coerceBooleanProperty(value); + } + + focus() { + this._element.nativeElement.focus(); + } + + /** 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. */ + protected _toggleSelectOnFocusedOption(): void { + if (!this.selectable) { + return; + } + + let focusedIndex = this._keyManager.activeItemIndex; + + if (typeof focusedIndex === 'number' && this._isValidIndex(focusedIndex)) { + let focusedOption: MdListOption = this.options.toArray()[focusedIndex]; + + if (focusedOption) { + focusedOption.toggle(); + } + } + } + + + /** + * Iterate through the list of options and add them to our list of + * subscribed options. + * + * @param options The list of options to be subscribed. + */ + protected _subscribeOptions(options: QueryList): void { + options.forEach(option => this._addOption(option)); + } + + /** + * Add a specific option to our subscribed list. If the option has + * already been subscribed, this ensures it is only subscribed + * once. + * + * @param option The option to be subscribed (or checked for existing + * subscription). + */ + protected _addOption(option: MdListOption) { + // If we've already been subscribed to a parent, do nothing + if (this._subscribed.has(option)) { + return; + } + + // Watch for focus events outside of the keyboard navigation + option.onFocus.subscribe(() => { + let optionIndex: number = this.options.toArray().indexOf(option); + + if (this._isValidIndex(optionIndex)) { + this._keyManager.updateActiveItemIndex(optionIndex); + } + }); + + // On destroy, remove the item from our list, and check focus + option.destroy.subscribe(() => { + let optionIndex: number = this.options.toArray().indexOf(option); + + if (this._isValidIndex(optionIndex) && 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); + } + } + + this._subscribed.delete(option); + option.destroy.unsubscribe(); + }); + + this._subscribed.set(option, true); + } + + /** + * 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; + } +}