Skip to content

Commit

Permalink
feat: add a common class to be used when dealing with selection logic (
Browse files Browse the repository at this point in the history
…#2562)

* feat: add a common class to be used when dealing with selection logic

Adds the `MdSelectionModel` class that can be used when dealing with single and multiple selection within a component.

Relates to #2412.

* Refactor and simplify based on the feedback.

* Rename private method.

* Move the clearing logic to _select and shuffle the method order.

* Rename private methods.
  • Loading branch information
crisbeto authored and tinayuangao committed Jan 13, 2017
1 parent e18ab5d commit c295fa9
Show file tree
Hide file tree
Showing 3 changed files with 306 additions and 0 deletions.
4 changes: 4 additions & 0 deletions src/lib/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
172 changes: 172 additions & 0 deletions src/lib/core/selection/selection.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import {SelectionModel} from './selection';


describe('SelectionModel', () => {
describe('single selection', () => {
let model: SelectionModel<any>;

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<any>;

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<any>;
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<any>;
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);
});
});
130 changes: 130 additions & 0 deletions src/lib/core/selection/selection.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
/** Currently-selected values. */
private _selection: Set<T> = 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<SelectionChange<T>> = 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<T> {
constructor(public added?: T[], public removed?: T[]) { }
}

0 comments on commit c295fa9

Please sign in to comment.