Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 7 commits into from Jan 13, 2017
4 changes: 4 additions & 0 deletions src/lib/core/core.ts
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
@@ -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
@@ -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._select(value));
} else {
this._select(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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's a bit confusing to have select and _select methods that do different things. Maybe make the private one something like _markSelected? (same for deselect and clear)

Copy link
Member Author

@crisbeto crisbeto Jan 11, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say it's probably better to move the clearing of the selected value to _select instead. That way the only difference is whether the event is emitted.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still think, though, that having a select and _select method is something we should avoid. It would be very easy to call the wrong one.

Copy link
Member Author

@crisbeto crisbeto Jan 11, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_markSelected makes sense, but should I rename _deselect and _clear in that case as well?

Copy link
Member Author

@crisbeto crisbeto Jan 11, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed up the names so they're not easily confused with the public ones.

if (!this._isMulti) {
this._clear();
}

this._select(value);
this._emitChangeEvent();
}

/**
* Deselects a value or an array of values.
*/
deselect(value: T): void {
this._deselect(value);
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;
}

/**
* Clears all of the selected values.
*/
clear(): void {
this._clear();
this._emitChangeEvent();
}

/** 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 _select(value: T) {
if (!this.isSelected(value)) {
this._selection.add(value);
this._selectedToEmit.push(value);
}
}

/** Deselects a value. */
private _deselect(value: T) {
if (this.isSelected(value)) {
this._selection.delete(value);
this._deselectedToEmit.push(value);
}
}

/** Clears out the selected values. */
private _clear() {
if (!this.isEmpty()) {
this._selection.forEach(value => this._deselect(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[]) { }
}