• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

atinc / ngx-tethys / 3033f133-0f0d-43eb-a07d-e1848354018a

07 Mar 2024 01:58AM UTC coverage: 90.58% (-0.02%) from 90.604%
3033f133-0f0d-43eb-a07d-e1848354018a

Pull #3022

circleci

web-flow
feat(schematics): improve schematics for select and custom-select in template #INFR-11735 (#3047)
Pull Request #3022: feat: upgrade ng to 17 #INFR-11427 (#3021)

5422 of 6642 branches covered (81.63%)

Branch coverage included in aggregate %.

328 of 338 new or added lines in 193 files covered. (97.04%)

141 existing lines in 29 files now uncovered.

13502 of 14250 relevant lines covered (94.75%)

982.04 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.78
/src/list/selection/selection-list.ts
1
import { ScrollToService } from 'ngx-tethys/core';
2
import { IThyListOptionParentComponent, THY_LIST_OPTION_PARENT_COMPONENT, ThyListLayout, ThyListOption } from 'ngx-tethys/shared';
3
import { coerceBooleanProperty, dom, helpers, keycodes } from 'ngx-tethys/util';
4
import { Subscription } from 'rxjs';
5
import { startWith } from 'rxjs/operators';
6
import { useHostRenderer } from '@tethys/cdk/dom';
7
import { ActiveDescendantKeyManager } from '@angular/cdk/a11y';
8
import { SelectionModel } from '@angular/cdk/collections';
9
import {
10
    AfterContentInit,
11
    ChangeDetectionStrategy,
12
    ChangeDetectorRef,
1✔
13
    Component,
14
    ContentChildren,
15
    ElementRef,
16
    EventEmitter,
17
    forwardRef,
18
    HostBinding,
19
    Input,
1✔
20
    NgZone,
21
    OnDestroy,
38✔
22
    OnInit,
38✔
23
    Output,
38✔
24
    QueryList,
11✔
25
    Renderer2
26
} from '@angular/core';
27
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
28

20✔
29
import { ThySelectionListChange } from './selection.interface';
20✔
30

31
export type ThyListSize = 'sm' | 'md' | 'lg';
32

29✔
33
const listSizesMap = {
34
    sm: 'thy-list-sm'
35
};
20✔
36

37
/**
38
 * @name thy-selection-list,[thy-selection-list]
28✔
39
 * @order 20
40
 */
41
@Component({
8✔
42
    selector: 'thy-selection-list,[thy-selection-list]',
43
    template: '<ng-content></ng-content>',
44
    providers: [
45
        {
46
            provide: THY_LIST_OPTION_PARENT_COMPONENT,
47
            useExisting: ThySelectionList
48
        },
49
        {
50
            provide: NG_VALUE_ACCESSOR,
10!
51
            useExisting: forwardRef(() => ThySelectionList),
10✔
52
            multi: true
10✔
53
        }
1✔
54
    ],
1✔
55
    changeDetection: ChangeDetectionStrategy.OnPush,
1✔
56
    standalone: true
57
})
1!
58
export class ThySelectionList implements OnInit, OnDestroy, AfterContentInit, IThyListOptionParentComponent, ControlValueAccessor {
1✔
59
    private _keyManager: ActiveDescendantKeyManager<ThyListOption>;
60

UNCOV
61
    private _selectionChangesUnsubscribe$ = Subscription.EMPTY;
×
62

×
63
    private _bindKeyEventUnsubscribe: () => void;
64

65
    private _modelValues: any[];
66

67
    private hostRenderer = useHostRenderer();
10✔
68

10✔
69
    /** The currently selected options. */
10✔
70
    selectionModel: SelectionModel<any>;
3✔
71

72
    disabled: boolean;
10✔
73

74
    layout: ThyListLayout = 'list';
75

76
    @HostBinding(`class.thy-list`) _isList = true;
4!
77

4✔
78
    @HostBinding(`class.thy-selection-list`) _isSelectionList = true;
4✔
79

80
    @HostBinding(`class.thy-multiple-selection-list`) multiple = true;
81

82
    @HostBinding(`class.thy-grid-list`) isLayoutGrid = false;
83

38✔
84
    /**
85
     * @internal
86
     */
87
    @ContentChildren(ThyListOption, { descendants: true }) options: QueryList<ThyListOption>;
88

13✔
89
    /**
90
     * 改变 grid item 的选择模式,使其支持多选
91
     * @default true
49✔
92
     */
93
    @Input()
94
    set thyMultiple(value: any) {
38✔
95
        const previousValue = this.multiple;
96
        this.multiple = coerceBooleanProperty(value);
97
        if (previousValue !== this.multiple) {
3!
UNCOV
98
            this._instanceSelectionModel();
×
99
        }
×
100
    }
101

3!
UNCOV
102
    /**
×
103
     * 绑定键盘事件的容器
104
     * @type HTMLElement | ElementRef | string
105
     * @default thy-selection-list 组件绑定的元素
3✔
106
     */
107
    @Input() thyBindKeyEventContainer: HTMLElement | ElementRef | string;
108

109
    /**
511✔
110
     * 出现滚动条的容器
507✔
111
     * @type HTMLElement | ElementRef | string
112
     * @default thy-selection-list 组件绑定的元素
113
     */
4✔
114
    @Input() thyScrollContainer: HTMLElement | ElementRef | string;
115

116
    /**
117
     * 键盘事件触发 Before 调用,如果返回 false 则停止继续执行
22✔
118
     */
22✔
119
    @Input() thyBeforeKeydown: (event?: KeyboardEvent) => boolean;
3✔
120

1✔
121
    /**
122
     * Option Value 唯一的 Key,用于存储哪些选择被选中的唯一值,只有 Option 的 thyValue 是对象的时才可以传入该选项
123
     */
2✔
124
    @Input() thyUniqueKey: string;
125

126
    /**
127
     * 比较2个选项的 Value 是否相同
128
     */
129
    @Input() thyCompareWith: (o1: any, o2: any) => boolean;
130

2✔
131
    /**
2✔
132
     * grid item 的展示样式
8✔
133
     * @type list | grid
8!
134
     * @default list
8✔
135
     */
8✔
136
    @Input() set thyLayout(value: ThyListLayout) {
137
        this.layout = value;
138
        this.isLayoutGrid = value === 'grid';
2!
139
    }
2✔
140

141
    /**
142
     * 是否自动激活第一项
143
     */
1✔
144
    @Input() set thyAutoActiveFirstItem(value: boolean) {
3✔
145
        this.autoActiveFirstItem = coerceBooleanProperty(value);
146
    }
147

148
    /**
1!
149
     * 改变 grid item 的大小,支持默认以及 sm 两种大小
1✔
150
     * @type sm | md | lg
151
     */
UNCOV
152
    @Input() set thySize(value: ThyListSize) {
×
153
        this._setListSize(value);
154
    }
155

156
    private spaceEnabled = true;
20✔
157

20!
158
    /**
20✔
159
     * 是否按下空格切换聚焦选项
160
     */
161
    @Input() set thySpaceKeyEnabled(value: boolean) {
20✔
162
        this.spaceEnabled = coerceBooleanProperty(value);
1✔
163
    }
164

165
    /**
166
     * 每当选项的选定状态发生更改时,都会触发更改事件
39✔
167
     * @type EventEmitter<ThySelectionListChange>
39✔
168
     */
39✔
169
    @Output() readonly thySelectionChange: EventEmitter<ThySelectionListChange> = new EventEmitter<ThySelectionListChange>();
39✔
170

39✔
171
    private autoActiveFirstItem: boolean;
39✔
172

39✔
173
    private _onTouched: () => void = () => {};
39✔
174

39✔
175
    private _onChange: (value: any) => void = (_: any) => {};
39✔
176

39✔
177
    private _emitChangeEvent(option: ThyListOption, event: Event) {
39✔
178
        this.thySelectionChange.emit({
39✔
179
            source: this,
39✔
180
            value: option.thyValue,
39✔
181
            option: option,
182
            event: event,
183
            selected: this.isSelected(option)
38✔
184
        });
38✔
185
    }
38✔
186

187
    private _emitModelValueChange() {
38✔
188
        if (this.options) {
189
            let selectedValues = this.selectionModel.selected;
190
            if (this.thyUniqueKey) {
42✔
191
                selectedValues = selectedValues.map(selectedValue => {
21!
UNCOV
192
                    const selectedOption = this.options.find(option => {
×
193
                        return option.thyValue[this.thyUniqueKey] === selectedValue;
194
                    });
21!
UNCOV
195
                    if (selectedOption) {
×
196
                        return selectedOption.thyValue;
197
                    } else {
198
                        return this._modelValues.find(value => {
42!
199
                            return value[this.thyUniqueKey] === selectedValue;
42✔
200
                        });
42✔
201
                    }
22✔
202
                });
203
            }
42✔
204
            this._modelValues = selectedValues;
205
            let changeValue = selectedValues;
206
            if (!this.multiple && selectedValues && selectedValues.length > 0) {
20✔
207
                changeValue = selectedValues[0];
208
            }
209
            this._onChange(changeValue);
20✔
210
        }
211
    }
212

20✔
213
    private _toggleFocusedOption(event: KeyboardEvent): void {
214
        if (this._keyManager.activeItem) {
215
            this.ngZone.run(() => {
8✔
216
                this.toggleOption(this._keyManager.activeItem, event);
217
            });
6✔
218
        }
6✔
219
    }
1✔
220

221
    private _initializeFocusKeyManager() {
222
        this._keyManager = new ActiveDescendantKeyManager<ThyListOption>(this.options)
7!
223
            .withWrap()
7✔
224
            // .withTypeAhead()
7✔
225
            // Allow disabled items to be focusable. For accessibility reasons, there must be a way for
7✔
226
            // screenreader users, that allows reading the different options of the list.
227
            .skipPredicate(() => false);
228
    }
5✔
229

1✔
230
    private _instanceSelectionModel() {
231
        this.selectionModel = new SelectionModel<any>(this.multiple);
4✔
232
    }
233

4✔
234
    private _getElementBySelector(element: HTMLElement | ElementRef | string): HTMLElement {
4✔
235
        return dom.getHTMLElementBySelector(element, this.elementRef);
236
    }
2✔
237

238
    private _compareValue(value1: any, value2: any) {
6!
239
        if (this.thyCompareWith) {
240
            const compareFn = this.thyCompareWith as (o1: any, o2: any) => boolean;
UNCOV
241
            return compareFn(value1, value2);
×
242
        } else if (this.thyUniqueKey) {
243
            return value1 && value1[this.thyUniqueKey] === value2 && value2[this.thyUniqueKey];
244
        } else {
245
            return value1 === value2;
8!
246
        }
8✔
247
    }
248

249
    private _getOptionSelectionValue(option: ThyListOption) {
8✔
250
        if (option.thyValue) {
8✔
251
            return this.thyUniqueKey ? option.thyValue[this.thyUniqueKey] : option.thyValue;
252
        } else {
253
            return option;
254
        }
6✔
255
    }
256

257
    private _setSelectionByValues(values: any[]) {
13✔
258
        this.selectionModel.clear();
13✔
259
        values.forEach(value => {
260
            if (this.thyUniqueKey) {
261
                this.selectionModel.select(value[this.thyUniqueKey]);
503✔
262
            } else {
263
                this.selectionModel.select(value);
264
            }
1!
265
        });
1✔
266
    }
267

268
    private _setAllOptionsSelected(toIsSelected: boolean) {
269
        // Keep track of whether anything changed, because we only want to
1!
270
        // emit the changed event when something actually changed.
1✔
271
        let hasChanged = false;
272

273
        this.options.forEach(option => {
274
            const fromIsSelected = this.selectionModel.isSelected(option.thyValue);
275
            if (fromIsSelected !== toIsSelected) {
1✔
276
                hasChanged = true;
277
                this.selectionModel.toggle(option.thyValue);
278
            }
279
        });
1✔
280

281
        if (hasChanged) {
282
            this._emitModelValueChange();
38✔
283
        }
38✔
284
    }
39✔
285

11!
286
    private _getOptionByValue(value: any) {
11✔
287
        return this.options.find(option => {
288
            return this._compareValue(option.thyValue, value);
289
        });
290
    }
291

292
    private _getActiveOption() {
39✔
293
        if (this._keyManager.activeItem) {
39✔
294
            return this._getOptionByValue(this._keyManager.activeItem.thyValue);
38✔
295
        } else {
296
            return null;
297
        }
1✔
298
    }
299

300
    private _setListSize(size: ThyListSize) {
301
        for (const key in listSizesMap) {
302
            if (listSizesMap.hasOwnProperty(key)) {
303
                this.hostRenderer.removeClass(listSizesMap[key]);
1✔
304
            }
305
        }
306
        if (size) {
307
            this.hostRenderer.addClass(listSizesMap[size]);
308
        }
309
    }
310

311
    constructor(
312
        private renderer: Renderer2,
313
        private elementRef: ElementRef,
314
        private ngZone: NgZone,
315
        private changeDetectorRef: ChangeDetectorRef
316
    ) {}
317

318
    ngOnInit() {
319
        const bindKeyEventElement = this._getElementBySelector(this.thyBindKeyEventContainer);
320
        this.ngZone.runOutsideAngular(() => {
321
            this._bindKeyEventUnsubscribe = this.renderer.listen(bindKeyEventElement, 'keydown', this.onKeydown.bind(this));
322
        });
1✔
323
        this._instanceSelectionModel();
324
    }
325

326
    writeValue(value: any[] | any): void {
327
        if ((typeof ngDevMode === 'undefined' || ngDevMode) && value) {
328
            if (this.multiple && !helpers.isArray(value)) {
329
                throw new Error(`The multiple selection ngModel must be an array.`);
330
            }
331
            if (!this.multiple && helpers.isArray(value)) {
332
                throw new Error(`The single selection ngModel should not be an array.`);
333
            }
20✔
334
        }
335
        const values = helpers.isArray(value) ? value : value ? [value] : [];
336
        this._modelValues = values;
337
        if (this.options) {
338
            this._setSelectionByValues(values);
339
        }
340
        this.changeDetectorRef.markForCheck();
341
    }
342

343
    registerOnChange(fn: any): void {
344
        this._onChange = fn;
345
    }
346

347
    registerOnTouched(fn: any): void {
348
        this._onTouched = fn;
349
    }
350

351
    setDisabledState(isDisabled: boolean): void {
352
        this.disabled = isDisabled;
353
    }
354

355
    onKeydown(event: KeyboardEvent) {
356
        if (this.thyBeforeKeydown) {
357
            // stop key down event
358
            const isContinue = this.thyBeforeKeydown(event);
359
            if (!isContinue) {
360
                return;
361
            }
362
        }
363
        const keyCode = event.keyCode || event.which;
364
        const manager = this._keyManager;
365
        const previousFocusIndex = manager.activeItemIndex;
366

367
        switch (keyCode) {
368
            case keycodes.SPACE:
369
            case keycodes.ENTER:
370
                if (keyCode === keycodes.SPACE && !this.spaceEnabled) {
371
                    return;
372
                }
373
                this._toggleFocusedOption(event);
374
                // Always prevent space from scrolling the page since the list has focus
375
                event.preventDefault();
376
                break;
377
            default:
378
                manager.onKeydown(event);
379
        }
380
        if (
381
            (keyCode === keycodes.UP_ARROW || keyCode === keycodes.DOWN_ARROW) &&
382
            event.shiftKey &&
383
            manager.activeItemIndex !== previousFocusIndex
384
        ) {
385
            this._toggleFocusedOption(event);
386
        }
387
    }
388

389
    toggleOption(option: ThyListOption, event?: Event) {
390
        if (option && !option.disabled) {
391
            this.selectionModel.toggle(this._getOptionSelectionValue(option));
392
            // Emit a change event because the focused option changed its state through user
393
            // interaction.
394
            this._emitModelValueChange();
395
            this._emitChangeEvent(option, event);
396
        }
397
    }
398

399
    setActiveOption(option: ThyListOption) {
400
        this._keyManager.updateActiveItem(option); // .updateActiveItemIndex(this._getOptionIndex(option));
401
    }
402

403
    scrollIntoView(option: ThyListOption) {
404
        const scrollContainerElement = dom.getHTMLElementBySelector(this.thyScrollContainer, this.elementRef);
405
        ScrollToService.scrollToElement(option.element.nativeElement, scrollContainerElement);
406
    }
407

408
    isSelected(option: ThyListOption) {
409
        return this.selectionModel.isSelected(this._getOptionSelectionValue(option));
410
    }
411

412
    clearActiveItem() {
413
        if (this._keyManager.activeItem) {
414
            this._keyManager.setActiveItem(-1);
415
        }
416
    }
417

418
    determineClearActiveItem() {
419
        if (!this._getActiveOption()) {
420
            this.clearActiveItem();
421
        }
422
    }
423

424
    /** Selects all of the options. */
425
    selectAll() {
426
        this._setAllOptionsSelected(true);
427
    }
428

429
    /** Deselects all of the options. */
430
    deselectAll() {
431
        this._setAllOptionsSelected(false);
432
    }
433

434
    ngAfterContentInit(): void {
435
        this._initializeFocusKeyManager();
436
        this.options.changes.pipe(startWith(true)).subscribe(() => {
437
            if (this.autoActiveFirstItem) {
438
                if (!this._keyManager.activeItem || this.options.toArray().indexOf(this._keyManager.activeItem) < 0) {
439
                    this._keyManager.setFirstItemActive();
440
                }
441
            }
442
        });
443
    }
444

445
    ngOnDestroy() {
446
        this._selectionChangesUnsubscribe$.unsubscribe();
447
        if (this._bindKeyEventUnsubscribe) {
448
            this._bindKeyEventUnsubscribe();
449
        }
450
    }
451
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc