diff --git a/src/lib/core/core.ts b/src/lib/core/core.ts index 52c9cec19928..52e670be9632 100644 --- a/src/lib/core/core.ts +++ b/src/lib/core/core.ts @@ -72,6 +72,10 @@ export { LIVE_ANNOUNCER_ELEMENT_TOKEN, LIVE_ANNOUNCER_PROVIDER, } from './a11y/live-announcer'; + +// Selection +export * from './selection/selection'; + /** @deprecated */ export {LiveAnnouncer as MdLiveAnnouncer} from './a11y/live-announcer'; diff --git a/src/lib/core/selection/selection.spec.ts b/src/lib/core/selection/selection.spec.ts new file mode 100644 index 000000000000..b05e5236d9fd --- /dev/null +++ b/src/lib/core/selection/selection.spec.ts @@ -0,0 +1,172 @@ +import {SelectionModel} from './selection'; + + +describe('SelectionModel', () => { + describe('single selection', () => { + let model: SelectionModel; + + beforeEach(() => model = new SelectionModel()); + + it('should be able to select a single value', () => { + model.select(1); + + expect(model.selected.length).toBe(1); + expect(model.isSelected(1)).toBe(true); + }); + + it('should deselect the previously selected value', () => { + model.select(1); + model.select(2); + + expect(model.isSelected(1)).toBe(false); + expect(model.isSelected(2)).toBe(true); + }); + + it('should only preselect one value', () => { + model = new SelectionModel(false, [1, 2]); + + expect(model.selected.length).toBe(1); + expect(model.isSelected(1)).toBe(true); + expect(model.isSelected(2)).toBe(false); + }); + }); + + describe('multiple selection', () => { + let model: SelectionModel; + + beforeEach(() => model = new SelectionModel(true)); + + it('should be able to select multiple options at the same time', () => { + model.select(1); + model.select(2); + + expect(model.selected.length).toBe(2); + expect(model.isSelected(1)).toBe(true); + expect(model.isSelected(2)).toBe(true); + }); + + it('should be able to preselect multiple options', () => { + model = new SelectionModel(true, [1, 2]); + + expect(model.selected.length).toBe(2); + expect(model.isSelected(1)).toBe(true); + expect(model.isSelected(2)).toBe(true); + }); + }); + + describe('onChange event', () => { + it('should return both the added and removed values', () => { + let model = new SelectionModel(); + let spy = jasmine.createSpy('SelectionModel change event'); + + model.select(1); + + model.onChange.subscribe(spy); + + model.select(2); + + let event = spy.calls.mostRecent().args[0]; + + expect(spy).toHaveBeenCalled(); + expect(event.removed).toEqual([1]); + expect(event.added).toEqual([2]); + }); + + describe('selection', () => { + let model: SelectionModel; + let spy: jasmine.Spy; + + beforeEach(() => { + model = new SelectionModel(true); + spy = jasmine.createSpy('SelectionModel change event'); + + model.onChange.subscribe(spy); + }); + + it('should emit an event when a value is selected', () => { + model.select(1); + + let event = spy.calls.mostRecent().args[0]; + + expect(spy).toHaveBeenCalled(); + expect(event.added).toEqual([1]); + expect(event.removed).toEqual([]); + }); + + it('should not emit multiple events for the same value', () => { + model.select(1); + model.select(1); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should not emit an event when preselecting values', () => { + model = new SelectionModel(false, [1]); + spy = jasmine.createSpy('SelectionModel initial change event'); + model.onChange.subscribe(spy); + + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe('deselection', () => { + let model: SelectionModel; + let spy: jasmine.Spy; + + beforeEach(() => { + model = new SelectionModel(true, [1, 2, 3]); + spy = jasmine.createSpy('SelectionModel change event'); + + model.onChange.subscribe(spy); + }); + + it('should emit an event when a value is deselected', () => { + model.deselect(1); + + let event = spy.calls.mostRecent().args[0]; + + expect(spy).toHaveBeenCalled(); + expect(event.removed).toEqual([1]); + }); + + it('should not emit an event when a non-selected value is deselected', () => { + model.deselect(4); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should emit a single event when clearing all of the selected options', () => { + model.clear(); + + let event = spy.calls.mostRecent().args[0]; + + expect(spy).toHaveBeenCalledTimes(1); + expect(event.removed).toEqual([1, 2, 3]); + }); + + }); + }); + + it('should be able to determine whether it is empty', () => { + let model = new SelectionModel(); + + expect(model.isEmpty()).toBe(true); + + model.select(1); + + expect(model.isEmpty()).toBe(false); + }); + + it('should be able to clear the selected options', () => { + let model = new SelectionModel(true); + + model.select(1); + model.select(2); + + expect(model.selected.length).toBe(2); + + model.clear(); + + expect(model.selected.length).toBe(0); + expect(model.isEmpty()).toBe(true); + }); +}); diff --git a/src/lib/core/selection/selection.ts b/src/lib/core/selection/selection.ts new file mode 100644 index 000000000000..91a594b51e7a --- /dev/null +++ b/src/lib/core/selection/selection.ts @@ -0,0 +1,130 @@ +import {Subject} from 'rxjs/Subject'; + + +/** + * Class to be used to power selecting one or more options from a list. + * @docs-private + */ +export class SelectionModel { + /** Currently-selected values. */ + private _selection: Set = new Set(); + + /** Keeps track of the deselected options that haven't been emitted by the change event. */ + private _deselectedToEmit: T[] = []; + + /** Keeps track of the selected option that haven't been emitted by the change event. */ + private _selectedToEmit: T[] = []; + + /** Cache for the array value of the selected items. */ + private _selected: T[]; + + /** Selected value(s). */ + get selected(): T[] { + if (!this._selected) { + this._selected = Array.from(this._selection.values()); + } + + return this._selected; + } + + /** Event emitted when the value has changed. */ + onChange: Subject> = new Subject(); + + constructor(private _isMulti = false, initiallySelectedValues?: T[]) { + if (initiallySelectedValues) { + if (_isMulti) { + initiallySelectedValues.forEach(value => this._markSelected(value)); + } else { + this._markSelected(initiallySelectedValues[0]); + } + + // Clear the array in order to avoid firing the change event for preselected values. + this._selectedToEmit.length = 0; + } + } + + /** + * Selects a value or an array of values. + */ + select(value: T): void { + this._markSelected(value); + this._emitChangeEvent(); + } + + /** + * Deselects a value or an array of values. + */ + deselect(value: T): void { + this._unmarkSelected(value); + this._emitChangeEvent(); + } + + /** + * Clears all of the selected values. + */ + clear(): void { + this._unmarkAll(); + this._emitChangeEvent(); + } + + /** + * Determines whether a value is selected. + */ + isSelected(value: T): boolean { + return this._selection.has(value); + } + + /** + * Determines whether the model has a value. + */ + isEmpty(): boolean { + return this._selection.size === 0; + } + + /** Emits a change event and clears the records of selected and deselected values. */ + private _emitChangeEvent() { + if (this._selectedToEmit.length || this._deselectedToEmit.length) { + let eventData = new SelectionChange(this._selectedToEmit, this._deselectedToEmit); + + this.onChange.next(eventData); + this._deselectedToEmit = []; + this._selectedToEmit = []; + this._selected = null; + } + } + + /** Selects a value. */ + private _markSelected(value: T) { + if (!this.isSelected(value)) { + if (!this._isMulti) { + this._unmarkAll(); + } + + this._selection.add(value); + this._selectedToEmit.push(value); + } + } + + /** Deselects a value. */ + private _unmarkSelected(value: T) { + if (this.isSelected(value)) { + this._selection.delete(value); + this._deselectedToEmit.push(value); + } + } + + /** Clears out the selected values. */ + private _unmarkAll() { + if (!this.isEmpty()) { + this._selection.forEach(value => this._unmarkSelected(value)); + } + } +} + +/** + * Describes an event emitted when the value of a MdSelectionModel has changed. + * @docs-private + */ +export class SelectionChange { + constructor(public added?: T[], public removed?: T[]) { } +}