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

atinc / ngx-tethys / 68ef226c-f83e-44c1-b8ed-e420a83c5d84

28 May 2025 10:31AM UTC coverage: 10.352% (-80.0%) from 90.316%
68ef226c-f83e-44c1-b8ed-e420a83c5d84

Pull #3460

circleci

pubuzhixing8
chore: xxx
Pull Request #3460: refactor(icon): migrate signal input #TINFR-1476

132 of 6823 branches covered (1.93%)

Branch coverage included in aggregate %.

10 of 14 new or added lines in 1 file covered. (71.43%)

11648 existing lines in 344 files now uncovered.

2078 of 14525 relevant lines covered (14.31%)

6.69 hits per line

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

1.73
/src/list/selection/selection-list.ts
1
import { ScrollToService } from 'ngx-tethys/core';
2
import { IThyListOptionParentComponent, THY_LIST_OPTION_PARENT_COMPONENT, 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,
UNCOV
21
    OnDestroy,
×
UNCOV
22
    OnInit,
×
UNCOV
23
    Output,
×
UNCOV
24
    QueryList,
×
UNCOV
25
    Renderer2,
×
UNCOV
26
    inject
×
UNCOV
27
} from '@angular/core';
×
UNCOV
28
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
×
UNCOV
29
import { ThyListLayout } from 'ngx-tethys/shared';
×
UNCOV
30
import { ThySelectionListChange } from './selection.interface';
×
UNCOV
31

×
UNCOV
32
export type ThyListSize = 'sm' | 'md' | 'lg';
×
UNCOV
33

×
UNCOV
34
const listSizesMap = {
×
UNCOV
35
    sm: 'thy-list-sm'
×
36
};
37

UNCOV
38
/**
×
UNCOV
39
 * @name thy-selection-list,[thy-selection-list]
×
UNCOV
40
 * @order 20
×
UNCOV
41
 */
×
42
@Component({
43
    selector: 'thy-selection-list,[thy-selection-list]',
44
    template: '<ng-content></ng-content>',
UNCOV
45
    providers: [
×
UNCOV
46
        {
×
47
            provide: THY_LIST_OPTION_PARENT_COMPONENT,
48
            useExisting: ThySelectionList
UNCOV
49
        },
×
50
        {
51
            provide: NG_VALUE_ACCESSOR,
UNCOV
52
            useExisting: forwardRef(() => ThySelectionList),
×
53
            multi: true
54
        }
UNCOV
55
    ],
×
56
    changeDetection: ChangeDetectionStrategy.OnPush
57
})
UNCOV
58
export class ThySelectionList implements OnInit, OnDestroy, AfterContentInit, IThyListOptionParentComponent, ControlValueAccessor {
×
59
    private renderer = inject(Renderer2);
60
    private elementRef = inject(ElementRef);
61
    private ngZone = inject(NgZone);
62
    private changeDetectorRef = inject(ChangeDetectorRef);
63

64
    private _keyManager: ActiveDescendantKeyManager<ThyListOption>;
65

66
    private _selectionChangesUnsubscribe$ = Subscription.EMPTY;
UNCOV
67

×
UNCOV
68
    private _bindKeyEventUnsubscribe: () => void;
×
UNCOV
69

×
UNCOV
70
    private _modelValues: any[];
×
UNCOV
71

×
UNCOV
72
    private hostRenderer = useHostRenderer();
×
73

UNCOV
74
    /** The currently selected options. */
×
UNCOV
75
    selectionModel: SelectionModel<any>;
×
76

77
    disabled: boolean;
78

×
79
    layout: ThyListLayout = 'list';
×
80

81
    @HostBinding(`class.thy-list`) _isList = true;
82

83
    @HostBinding(`class.thy-selection-list`) _isSelectionList = true;
UNCOV
84

×
UNCOV
85
    @HostBinding(`class.thy-multiple-selection-list`) multiple = true;
×
UNCOV
86

×
UNCOV
87
    @HostBinding(`class.thy-grid-list`) isLayoutGrid = false;
×
88

UNCOV
89
    /**
×
90
     * @internal
91
     */
92
    @ContentChildren(ThyListOption, { descendants: true }) options: QueryList<ThyListOption>;
UNCOV
93

×
UNCOV
94
    /**
×
UNCOV
95
     * 改变 grid item 的选择模式,使其支持多选
×
96
     * @default true
97
     */
98
    @Input({ transform: coerceBooleanProperty })
99
    set thyMultiple(value: boolean) {
UNCOV
100
        const previousValue = this.multiple;
×
101
        this.multiple = value;
102
        if (previousValue !== this.multiple) {
103
            this._instanceSelectionModel();
104
        }
UNCOV
105
    }
×
106

107
    /**
UNCOV
108
     * 绑定键盘事件的容器
×
109
     * @type HTMLElement | ElementRef | string
110
     * @default thy-selection-list 组件绑定的元素
UNCOV
111
     */
×
112
    @Input() thyBindKeyEventContainer: HTMLElement | ElementRef | string;
113

UNCOV
114
    /**
×
115
     * 出现滚动条的容器
×
116
     * @type HTMLElement | ElementRef | string
×
117
     * @default thy-selection-list 组件绑定的元素
UNCOV
118
     */
×
119
    @Input() thyScrollContainer: HTMLElement | ElementRef | string;
×
120

121
    /**
UNCOV
122
     * 键盘事件触发 Before 调用,如果返回 false 则停止继续执行
×
123
     */
124
    @Input() thyBeforeKeydown: (event?: KeyboardEvent) => boolean;
125

UNCOV
126
    /**
×
UNCOV
127
     * Option Value 唯一的 Key,用于存储哪些选择被选中的唯一值,只有 Option 的 thyValue 是对象的时才可以传入该选项
×
128
     */
129
    @Input() thyUniqueKey: string;
UNCOV
130

×
131
    /**
132
     * 比较2个选项的 Value 是否相同
133
     */
UNCOV
134
    @Input() thyCompareWith: (o1: any, o2: any) => boolean;
×
UNCOV
135

×
UNCOV
136
    /**
×
UNCOV
137
     * grid item 的展示样式
×
138
     * @type list | grid
139
     * @default list
UNCOV
140
     */
×
141
    @Input() set thyLayout(value: ThyListLayout) {
142
        this.layout = value;
143
        this.isLayoutGrid = value === 'grid';
144
    }
145

146
    /**
UNCOV
147
     * 是否自动激活第一项
×
UNCOV
148
     */
×
UNCOV
149
    @Input({ transform: coerceBooleanProperty }) set thyAutoActiveFirstItem(value: boolean) {
×
UNCOV
150
        this.autoActiveFirstItem = value;
×
UNCOV
151
    }
×
UNCOV
152

×
153
    /**
154
     * 改变 grid item 的大小,支持默认以及 sm 两种大小
UNCOV
155
     * @type sm | md | lg
×
UNCOV
156
     */
×
157
    @Input() set thySize(value: ThyListSize) {
158
        this._setListSize(value);
159
    }
UNCOV
160

×
UNCOV
161
    private spaceEnabled = true;
×
162

163
    /**
164
     * 是否按下空格切换聚焦选项
UNCOV
165
     */
×
UNCOV
166
    @Input({ transform: coerceBooleanProperty }) set thySpaceKeyEnabled(value: boolean) {
×
167
        this.spaceEnabled = value;
168
    }
169

×
170
    /**
171
     * 每当选项的选定状态发生更改时,都会触发更改事件
172
     * @type EventEmitter<ThySelectionListChange>
UNCOV
173
     */
×
UNCOV
174
    @Output() readonly thySelectionChange: EventEmitter<ThySelectionListChange> = new EventEmitter<ThySelectionListChange>();
×
UNCOV
175

×
176
    private autoActiveFirstItem: boolean;
177

UNCOV
178
    private _onTouched: () => void = () => {};
×
UNCOV
179

×
180
    private _onChange: (value: any) => void = (_: any) => {};
181

182
    private _emitChangeEvent(option: ThyListOption, event: Event) {
UNCOV
183
        this.thySelectionChange.emit({
×
UNCOV
184
            source: this,
×
UNCOV
185
            value: option.thyValue,
×
186
            option: option,
UNCOV
187
            event: event,
×
188
            selected: this.isSelected(option)
189
        });
UNCOV
190
    }
×
UNCOV
191

×
192
    private _emitModelValueChange() {
×
193
        if (this.options) {
UNCOV
194
            let selectedValues = this.selectionModel.selected;
×
195
            if (this.thyUniqueKey) {
×
196
                selectedValues = selectedValues.map(selectedValue => {
197
                    const selectedOption = this.options.find(option => {
UNCOV
198
                        return option.thyValue[this.thyUniqueKey] === selectedValue;
×
UNCOV
199
                    });
×
UNCOV
200
                    if (selectedOption) {
×
UNCOV
201
                        return selectedOption.thyValue;
×
202
                    } else {
UNCOV
203
                        return this._modelValues.find(value => {
×
204
                            return value[this.thyUniqueKey] === selectedValue;
205
                        });
UNCOV
206
                    }
×
207
                });
208
            }
UNCOV
209
            this._modelValues = selectedValues;
×
210
            let changeValue = selectedValues;
211
            if (!this.multiple && selectedValues && selectedValues.length > 0) {
UNCOV
212
                changeValue = selectedValues[0];
×
213
            }
214
            this._onChange(changeValue);
UNCOV
215
        }
×
216
    }
UNCOV
217

×
UNCOV
218
    private _toggleFocusedOption(event: KeyboardEvent): void {
×
UNCOV
219
        if (this._keyManager.activeItem) {
×
220
            this.ngZone.run(() => {
221
                this.toggleOption(this._keyManager.activeItem, event);
UNCOV
222
            });
×
UNCOV
223
        }
×
UNCOV
224
    }
×
UNCOV
225

×
226
    private _initializeFocusKeyManager() {
227
        this._keyManager = new ActiveDescendantKeyManager<ThyListOption>(this.options)
UNCOV
228
            .withWrap()
×
UNCOV
229
            // .withTypeAhead()
×
230
            // Allow disabled items to be focusable. For accessibility reasons, there must be a way for
UNCOV
231
            // screenreader users, that allows reading the different options of the list.
×
232
            .skipPredicate(() => false);
UNCOV
233
    }
×
UNCOV
234

×
235
    private _instanceSelectionModel() {
UNCOV
236
        this.selectionModel = new SelectionModel<any>(this.multiple);
×
237
    }
UNCOV
238

×
239
    private _getElementBySelector(element: HTMLElement | ElementRef | string): HTMLElement {
240
        return dom.getHTMLElementBySelector(element, this.elementRef);
241
    }
×
242

243
    private _compareValue(value1: any, value2: any) {
244
        if (this.thyCompareWith) {
UNCOV
245
            const compareFn = this.thyCompareWith as (o1: any, o2: any) => boolean;
×
UNCOV
246
            return compareFn(value1, value2);
×
247
        } else if (this.thyUniqueKey) {
248
            return value1 && value1[this.thyUniqueKey] === value2 && value2[this.thyUniqueKey];
UNCOV
249
        } else {
×
UNCOV
250
            return value1 === value2;
×
251
        }
252
    }
253

UNCOV
254
    private _getOptionSelectionValue(option: ThyListOption) {
×
255
        if (option.thyValue) {
256
            return this.thyUniqueKey ? option.thyValue[this.thyUniqueKey] : option.thyValue;
UNCOV
257
        } else {
×
UNCOV
258
            return option;
×
259
        }
260
    }
UNCOV
261

×
262
    private _setSelectionByValues(values: any[]) {
263
        this.selectionModel.clear();
UNCOV
264
        values.forEach(value => {
×
UNCOV
265
            if (this.thyUniqueKey) {
×
266
                this.selectionModel.select(value[this.thyUniqueKey]);
267
            } else {
268
                this.selectionModel.select(value);
UNCOV
269
            }
×
UNCOV
270
        });
×
271
    }
272

273
    private _setAllOptionsSelected(toIsSelected: boolean) {
274
        // Keep track of whether anything changed, because we only want to
UNCOV
275
        // emit the changed event when something actually changed.
×
276
        let hasChanged = false;
277

278
        this.options.forEach(option => {
UNCOV
279
            const fromIsSelected = this.selectionModel.isSelected(option.thyValue);
×
280
            if (fromIsSelected !== toIsSelected) {
281
                hasChanged = true;
UNCOV
282
                this.selectionModel.toggle(option.thyValue);
×
UNCOV
283
            }
×
UNCOV
284
        });
×
UNCOV
285

×
UNCOV
286
        if (hasChanged) {
×
287
            this._emitModelValueChange();
288
        }
289
    }
290

291
    private _getOptionByValue(value: any) {
UNCOV
292
        return this.options.find(option => {
×
UNCOV
293
            return this._compareValue(option.thyValue, value);
×
UNCOV
294
        });
×
295
    }
296

297
    private _getActiveOption() {
1✔
298
        if (this._keyManager.activeItem) {
299
            return this._getOptionByValue(this._keyManager.activeItem.thyValue);
300
        } else {
301
            return null;
302
        }
303
    }
304

305
    private _setListSize(size: ThyListSize) {
306
        for (const key in listSizesMap) {
307
            if (listSizesMap.hasOwnProperty(key)) {
308
                this.hostRenderer.removeClass(listSizesMap[key]);
309
            }
310
        }
311
        if (size) {
312
            this.hostRenderer.addClass(listSizesMap[size]);
313
        }
314
    }
315

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

443
    ngOnDestroy() {
444
        this._selectionChangesUnsubscribe$.unsubscribe();
445
        if (this._bindKeyEventUnsubscribe) {
446
            this._bindKeyEventUnsubscribe();
447
        }
448
    }
449
}
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