Skip to content

Commit c295fa9

Browse files
crisbetotinayuangao
authored andcommitted
feat: add a common class to be used when dealing with selection logic (#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.
1 parent e18ab5d commit c295fa9

File tree

3 files changed

+306
-0
lines changed

3 files changed

+306
-0
lines changed

src/lib/core/core.ts

+4
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ export {
7272
LIVE_ANNOUNCER_ELEMENT_TOKEN,
7373
LIVE_ANNOUNCER_PROVIDER,
7474
} from './a11y/live-announcer';
75+
76+
// Selection
77+
export * from './selection/selection';
78+
7579
/** @deprecated */
7680
export {LiveAnnouncer as MdLiveAnnouncer} from './a11y/live-announcer';
7781

+172
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import {SelectionModel} from './selection';
2+
3+
4+
describe('SelectionModel', () => {
5+
describe('single selection', () => {
6+
let model: SelectionModel<any>;
7+
8+
beforeEach(() => model = new SelectionModel());
9+
10+
it('should be able to select a single value', () => {
11+
model.select(1);
12+
13+
expect(model.selected.length).toBe(1);
14+
expect(model.isSelected(1)).toBe(true);
15+
});
16+
17+
it('should deselect the previously selected value', () => {
18+
model.select(1);
19+
model.select(2);
20+
21+
expect(model.isSelected(1)).toBe(false);
22+
expect(model.isSelected(2)).toBe(true);
23+
});
24+
25+
it('should only preselect one value', () => {
26+
model = new SelectionModel(false, [1, 2]);
27+
28+
expect(model.selected.length).toBe(1);
29+
expect(model.isSelected(1)).toBe(true);
30+
expect(model.isSelected(2)).toBe(false);
31+
});
32+
});
33+
34+
describe('multiple selection', () => {
35+
let model: SelectionModel<any>;
36+
37+
beforeEach(() => model = new SelectionModel(true));
38+
39+
it('should be able to select multiple options at the same time', () => {
40+
model.select(1);
41+
model.select(2);
42+
43+
expect(model.selected.length).toBe(2);
44+
expect(model.isSelected(1)).toBe(true);
45+
expect(model.isSelected(2)).toBe(true);
46+
});
47+
48+
it('should be able to preselect multiple options', () => {
49+
model = new SelectionModel(true, [1, 2]);
50+
51+
expect(model.selected.length).toBe(2);
52+
expect(model.isSelected(1)).toBe(true);
53+
expect(model.isSelected(2)).toBe(true);
54+
});
55+
});
56+
57+
describe('onChange event', () => {
58+
it('should return both the added and removed values', () => {
59+
let model = new SelectionModel();
60+
let spy = jasmine.createSpy('SelectionModel change event');
61+
62+
model.select(1);
63+
64+
model.onChange.subscribe(spy);
65+
66+
model.select(2);
67+
68+
let event = spy.calls.mostRecent().args[0];
69+
70+
expect(spy).toHaveBeenCalled();
71+
expect(event.removed).toEqual([1]);
72+
expect(event.added).toEqual([2]);
73+
});
74+
75+
describe('selection', () => {
76+
let model: SelectionModel<any>;
77+
let spy: jasmine.Spy;
78+
79+
beforeEach(() => {
80+
model = new SelectionModel(true);
81+
spy = jasmine.createSpy('SelectionModel change event');
82+
83+
model.onChange.subscribe(spy);
84+
});
85+
86+
it('should emit an event when a value is selected', () => {
87+
model.select(1);
88+
89+
let event = spy.calls.mostRecent().args[0];
90+
91+
expect(spy).toHaveBeenCalled();
92+
expect(event.added).toEqual([1]);
93+
expect(event.removed).toEqual([]);
94+
});
95+
96+
it('should not emit multiple events for the same value', () => {
97+
model.select(1);
98+
model.select(1);
99+
100+
expect(spy).toHaveBeenCalledTimes(1);
101+
});
102+
103+
it('should not emit an event when preselecting values', () => {
104+
model = new SelectionModel(false, [1]);
105+
spy = jasmine.createSpy('SelectionModel initial change event');
106+
model.onChange.subscribe(spy);
107+
108+
expect(spy).not.toHaveBeenCalled();
109+
});
110+
});
111+
112+
describe('deselection', () => {
113+
let model: SelectionModel<any>;
114+
let spy: jasmine.Spy;
115+
116+
beforeEach(() => {
117+
model = new SelectionModel(true, [1, 2, 3]);
118+
spy = jasmine.createSpy('SelectionModel change event');
119+
120+
model.onChange.subscribe(spy);
121+
});
122+
123+
it('should emit an event when a value is deselected', () => {
124+
model.deselect(1);
125+
126+
let event = spy.calls.mostRecent().args[0];
127+
128+
expect(spy).toHaveBeenCalled();
129+
expect(event.removed).toEqual([1]);
130+
});
131+
132+
it('should not emit an event when a non-selected value is deselected', () => {
133+
model.deselect(4);
134+
expect(spy).not.toHaveBeenCalled();
135+
});
136+
137+
it('should emit a single event when clearing all of the selected options', () => {
138+
model.clear();
139+
140+
let event = spy.calls.mostRecent().args[0];
141+
142+
expect(spy).toHaveBeenCalledTimes(1);
143+
expect(event.removed).toEqual([1, 2, 3]);
144+
});
145+
146+
});
147+
});
148+
149+
it('should be able to determine whether it is empty', () => {
150+
let model = new SelectionModel();
151+
152+
expect(model.isEmpty()).toBe(true);
153+
154+
model.select(1);
155+
156+
expect(model.isEmpty()).toBe(false);
157+
});
158+
159+
it('should be able to clear the selected options', () => {
160+
let model = new SelectionModel(true);
161+
162+
model.select(1);
163+
model.select(2);
164+
165+
expect(model.selected.length).toBe(2);
166+
167+
model.clear();
168+
169+
expect(model.selected.length).toBe(0);
170+
expect(model.isEmpty()).toBe(true);
171+
});
172+
});

src/lib/core/selection/selection.ts

+130
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import {Subject} from 'rxjs/Subject';
2+
3+
4+
/**
5+
* Class to be used to power selecting one or more options from a list.
6+
* @docs-private
7+
*/
8+
export class SelectionModel<T> {
9+
/** Currently-selected values. */
10+
private _selection: Set<T> = new Set();
11+
12+
/** Keeps track of the deselected options that haven't been emitted by the change event. */
13+
private _deselectedToEmit: T[] = [];
14+
15+
/** Keeps track of the selected option that haven't been emitted by the change event. */
16+
private _selectedToEmit: T[] = [];
17+
18+
/** Cache for the array value of the selected items. */
19+
private _selected: T[];
20+
21+
/** Selected value(s). */
22+
get selected(): T[] {
23+
if (!this._selected) {
24+
this._selected = Array.from(this._selection.values());
25+
}
26+
27+
return this._selected;
28+
}
29+
30+
/** Event emitted when the value has changed. */
31+
onChange: Subject<SelectionChange<T>> = new Subject();
32+
33+
constructor(private _isMulti = false, initiallySelectedValues?: T[]) {
34+
if (initiallySelectedValues) {
35+
if (_isMulti) {
36+
initiallySelectedValues.forEach(value => this._markSelected(value));
37+
} else {
38+
this._markSelected(initiallySelectedValues[0]);
39+
}
40+
41+
// Clear the array in order to avoid firing the change event for preselected values.
42+
this._selectedToEmit.length = 0;
43+
}
44+
}
45+
46+
/**
47+
* Selects a value or an array of values.
48+
*/
49+
select(value: T): void {
50+
this._markSelected(value);
51+
this._emitChangeEvent();
52+
}
53+
54+
/**
55+
* Deselects a value or an array of values.
56+
*/
57+
deselect(value: T): void {
58+
this._unmarkSelected(value);
59+
this._emitChangeEvent();
60+
}
61+
62+
/**
63+
* Clears all of the selected values.
64+
*/
65+
clear(): void {
66+
this._unmarkAll();
67+
this._emitChangeEvent();
68+
}
69+
70+
/**
71+
* Determines whether a value is selected.
72+
*/
73+
isSelected(value: T): boolean {
74+
return this._selection.has(value);
75+
}
76+
77+
/**
78+
* Determines whether the model has a value.
79+
*/
80+
isEmpty(): boolean {
81+
return this._selection.size === 0;
82+
}
83+
84+
/** Emits a change event and clears the records of selected and deselected values. */
85+
private _emitChangeEvent() {
86+
if (this._selectedToEmit.length || this._deselectedToEmit.length) {
87+
let eventData = new SelectionChange(this._selectedToEmit, this._deselectedToEmit);
88+
89+
this.onChange.next(eventData);
90+
this._deselectedToEmit = [];
91+
this._selectedToEmit = [];
92+
this._selected = null;
93+
}
94+
}
95+
96+
/** Selects a value. */
97+
private _markSelected(value: T) {
98+
if (!this.isSelected(value)) {
99+
if (!this._isMulti) {
100+
this._unmarkAll();
101+
}
102+
103+
this._selection.add(value);
104+
this._selectedToEmit.push(value);
105+
}
106+
}
107+
108+
/** Deselects a value. */
109+
private _unmarkSelected(value: T) {
110+
if (this.isSelected(value)) {
111+
this._selection.delete(value);
112+
this._deselectedToEmit.push(value);
113+
}
114+
}
115+
116+
/** Clears out the selected values. */
117+
private _unmarkAll() {
118+
if (!this.isEmpty()) {
119+
this._selection.forEach(value => this._unmarkSelected(value));
120+
}
121+
}
122+
}
123+
124+
/**
125+
* Describes an event emitted when the value of a MdSelectionModel has changed.
126+
* @docs-private
127+
*/
128+
export class SelectionChange<T> {
129+
constructor(public added?: T[], public removed?: T[]) { }
130+
}

0 commit comments

Comments
 (0)