ngx-mat-searchable-select 2.0.2 → 2.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fesm2022/ngx-mat-searchable-select.mjs +245 -0
- package/fesm2022/ngx-mat-searchable-select.mjs.map +1 -0
- package/package.json +27 -12
- package/types/ngx-mat-searchable-select.d.ts +125 -0
- package/karma.conf.js +0 -44
- package/ng-package.json +0 -7
- package/src/lib/mat-list-shared.component.html +0 -75
- package/src/lib/mat-list-shared.component.spec.ts +0 -229
- package/src/lib/mat-list-shared.component.ts +0 -168
- package/src/lib/mat-list-shared.mock.ts +0 -43
- package/src/lib/mat-list-shared.model.ts +0 -40
- package/src/lib/mat-list-shared.service.spec.ts +0 -16
- package/src/lib/mat-select-infinite-scroll.directive.ts +0 -52
- package/src/public-api.ts +0 -8
- package/src/test.ts +0 -15
- package/tsconfig.lib.json +0 -15
- package/tsconfig.lib.prod.json +0 -10
- package/tsconfig.spec.json +0 -17
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { EventEmitter, Output, Input, Self, Directive, inject, DestroyRef, input, output, signal, computed, Component } from '@angular/core';
|
|
3
|
+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
4
|
+
import * as i1$1 from '@angular/forms';
|
|
5
|
+
import { FormBuilder, FormsModule, ReactiveFormsModule } from '@angular/forms';
|
|
6
|
+
import * as i2 from '@angular/material/form-field';
|
|
7
|
+
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
8
|
+
import * as i1 from '@angular/material/select';
|
|
9
|
+
import { MatSelectModule } from '@angular/material/select';
|
|
10
|
+
import * as i4 from '@angular/material/input';
|
|
11
|
+
import { MatInputModule } from '@angular/material/input';
|
|
12
|
+
import * as i5 from '@angular/material/button';
|
|
13
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
14
|
+
import * as i6 from '@angular/material/icon';
|
|
15
|
+
import { MatIconModule } from '@angular/material/icon';
|
|
16
|
+
import { of, Subscription } from 'rxjs';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A ready-made data source backed by a static in-memory array.
|
|
20
|
+
*
|
|
21
|
+
* Use this when you want to demo or test the component without a real API:
|
|
22
|
+
*
|
|
23
|
+
* ```ts
|
|
24
|
+
* const dataSource = new MockSearchableSelectDataSource([
|
|
25
|
+
* { id: 1, name: 'Paris' },
|
|
26
|
+
* { id: 2, name: 'London' },
|
|
27
|
+
* ]);
|
|
28
|
+
*
|
|
29
|
+
* this.config = {
|
|
30
|
+
* dataSource,
|
|
31
|
+
* option: { ... },
|
|
32
|
+
* mode: 'create',
|
|
33
|
+
* };
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* Search is applied across all string-coercible fields of each item.
|
|
37
|
+
* Pagination uses the `skip` / `take` values from the request.
|
|
38
|
+
*/
|
|
39
|
+
class MockSearchableSelectDataSource {
|
|
40
|
+
constructor(items) {
|
|
41
|
+
this.items = items;
|
|
42
|
+
}
|
|
43
|
+
getAll(request) {
|
|
44
|
+
const term = request.searchString?.toLowerCase() ?? '';
|
|
45
|
+
const filtered = term
|
|
46
|
+
? this.items.filter(item => Object.values(item).some(value => String(value ?? '').toLowerCase().includes(term)))
|
|
47
|
+
: this.items;
|
|
48
|
+
const page = filtered.slice(request.skip, request.skip + request.take);
|
|
49
|
+
return of({ data: page, totalCount: filtered.length });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
class MatSelectInfiniteScrollDirective {
|
|
54
|
+
constructor(matSelect) {
|
|
55
|
+
this.matSelect = matSelect;
|
|
56
|
+
this.complete = false;
|
|
57
|
+
this.threshold = 0.8;
|
|
58
|
+
this.infiniteScroll = new EventEmitter();
|
|
59
|
+
this.subscription = new Subscription();
|
|
60
|
+
this.boundOnScroll = this.onScroll.bind(this);
|
|
61
|
+
this.subscription.add(this.matSelect.openedChange.subscribe((isOpen) => {
|
|
62
|
+
if (isOpen) {
|
|
63
|
+
setTimeout(() => this.attachScrollListener());
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
this.detachScrollListener();
|
|
67
|
+
}
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
attachScrollListener() {
|
|
71
|
+
const panel = this.matSelect.panel?.nativeElement;
|
|
72
|
+
panel?.addEventListener('scroll', this.boundOnScroll);
|
|
73
|
+
}
|
|
74
|
+
detachScrollListener() {
|
|
75
|
+
const panel = this.matSelect.panel?.nativeElement;
|
|
76
|
+
panel?.removeEventListener('scroll', this.boundOnScroll);
|
|
77
|
+
}
|
|
78
|
+
onScroll(event) {
|
|
79
|
+
if (this.complete)
|
|
80
|
+
return;
|
|
81
|
+
const el = event.target;
|
|
82
|
+
const scrollRatio = (el.scrollTop + el.clientHeight) / el.scrollHeight;
|
|
83
|
+
if (scrollRatio >= this.threshold) {
|
|
84
|
+
this.infiniteScroll.emit();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
ngOnDestroy() {
|
|
88
|
+
this.detachScrollListener();
|
|
89
|
+
this.subscription.unsubscribe();
|
|
90
|
+
}
|
|
91
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MatSelectInfiniteScrollDirective, deps: [{ token: i1.MatSelect, self: true }], target: i0.ɵɵFactoryTarget.Directive }); }
|
|
92
|
+
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.1.3", type: MatSelectInfiniteScrollDirective, isStandalone: true, selector: "mat-select[matSelectInfiniteScroll]", inputs: { complete: "complete", threshold: "threshold" }, outputs: { infiniteScroll: "infiniteScroll" }, ngImport: i0 }); }
|
|
93
|
+
}
|
|
94
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MatSelectInfiniteScrollDirective, decorators: [{
|
|
95
|
+
type: Directive,
|
|
96
|
+
args: [{
|
|
97
|
+
selector: 'mat-select[matSelectInfiniteScroll]',
|
|
98
|
+
standalone: true,
|
|
99
|
+
}]
|
|
100
|
+
}], ctorParameters: () => [{ type: i1.MatSelect, decorators: [{
|
|
101
|
+
type: Self
|
|
102
|
+
}] }], propDecorators: { complete: [{
|
|
103
|
+
type: Input
|
|
104
|
+
}], threshold: [{
|
|
105
|
+
type: Input
|
|
106
|
+
}], infiniteScroll: [{
|
|
107
|
+
type: Output
|
|
108
|
+
}] } });
|
|
109
|
+
|
|
110
|
+
const SEARCH_DELAY = 1000;
|
|
111
|
+
const INITIAL_BATCH_SIZE = 5;
|
|
112
|
+
const SUBSEQUENT_BATCH_SIZE = 10;
|
|
113
|
+
class NgxMatSearchableSelectComponent {
|
|
114
|
+
constructor() {
|
|
115
|
+
this.destroyRef = inject(DestroyRef);
|
|
116
|
+
// Signal-based inputs
|
|
117
|
+
this.parentForm = input.required(...(ngDevMode ? [{ debugName: "parentForm" }] : []));
|
|
118
|
+
this.config = input.required(...(ngDevMode ? [{ debugName: "config" }] : []));
|
|
119
|
+
/** Optional static item list. When provided, no dataSource or server call is needed. */
|
|
120
|
+
this.staticItems = input(...(ngDevMode ? [undefined, { debugName: "staticItems" }] : []));
|
|
121
|
+
// Signal-based outputs
|
|
122
|
+
this.selectionChange = output();
|
|
123
|
+
this.valueChange = output();
|
|
124
|
+
// Internal search form (inline inject — no constructor needed)
|
|
125
|
+
this.searchForm = inject(FormBuilder).group({ searchTerm: '' });
|
|
126
|
+
// State signals
|
|
127
|
+
this.items = signal([], ...(ngDevMode ? [{ debugName: "items" }] : []));
|
|
128
|
+
this.isLoading = signal(false, ...(ngDevMode ? [{ debugName: "isLoading" }] : []));
|
|
129
|
+
this.offset = signal(0, ...(ngDevMode ? [{ debugName: "offset" }] : []));
|
|
130
|
+
this.totalItems = signal(0, ...(ngDevMode ? [{ debugName: "totalItems" }] : []));
|
|
131
|
+
this.hasLoaded = signal(false, ...(ngDevMode ? [{ debugName: "hasLoaded" }] : []));
|
|
132
|
+
this.isItemSelected = signal(false, ...(ngDevMode ? [{ debugName: "isItemSelected" }] : []));
|
|
133
|
+
// Computed: true when all pages have been loaded
|
|
134
|
+
this.isScrollComplete = computed(() => this.totalItems() > 0 && this.offset() >= this.totalItems(), ...(ngDevMode ? [{ debugName: "isScrollComplete" }] : []));
|
|
135
|
+
// Computed: true when a fetch completed but returned zero results
|
|
136
|
+
this.noItemsFound = computed(() => this.hasLoaded() && !this.isLoading() && this.items().length === 0, ...(ngDevMode ? [{ debugName: "noItemsFound" }] : []));
|
|
137
|
+
// Computed: show the pre-selected edit option when it is not in the loaded pages
|
|
138
|
+
this.showEditOption = computed(() => {
|
|
139
|
+
const cfg = this.config();
|
|
140
|
+
if (cfg.mode !== 'edit')
|
|
141
|
+
return false;
|
|
142
|
+
if (!cfg.option.currentLabel)
|
|
143
|
+
return false;
|
|
144
|
+
return !this.items().some(item => item['id'] === cfg.option.currentId);
|
|
145
|
+
}, ...(ngDevMode ? [{ debugName: "showEditOption" }] : []));
|
|
146
|
+
}
|
|
147
|
+
ngOnInit() {
|
|
148
|
+
const staticData = this.staticItems();
|
|
149
|
+
this.dataSource = staticData
|
|
150
|
+
? new MockSearchableSelectDataSource(staticData)
|
|
151
|
+
: this.config().dataSource;
|
|
152
|
+
const filter = this.config().filter;
|
|
153
|
+
this.queryState = {
|
|
154
|
+
take: INITIAL_BATCH_SIZE,
|
|
155
|
+
skip: 0,
|
|
156
|
+
searchString: '',
|
|
157
|
+
...(filter?.id !== undefined ? { id: filter.id } : {}),
|
|
158
|
+
};
|
|
159
|
+
this.fetchNextPage();
|
|
160
|
+
}
|
|
161
|
+
fetchNextPage() {
|
|
162
|
+
if (this.isLoading() || this.isScrollComplete())
|
|
163
|
+
return;
|
|
164
|
+
this.isLoading.set(true);
|
|
165
|
+
this.dataSource.getAll(this.queryState)
|
|
166
|
+
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
167
|
+
.subscribe(response => {
|
|
168
|
+
this.items.update(prev => [...prev, ...response.data]);
|
|
169
|
+
this.totalItems.set(response.totalCount);
|
|
170
|
+
this.offset.update(n => n + response.data.length);
|
|
171
|
+
this.queryState = {
|
|
172
|
+
...this.queryState,
|
|
173
|
+
take: SUBSEQUENT_BATCH_SIZE,
|
|
174
|
+
skip: this.offset(),
|
|
175
|
+
};
|
|
176
|
+
this.hasLoaded.set(true);
|
|
177
|
+
this.isLoading.set(false);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
onPanelClosed() {
|
|
181
|
+
if (!this.isItemSelected()) {
|
|
182
|
+
this.resetSearch();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
getDisplayLabel(item) {
|
|
186
|
+
return String(item[this.config().option.displayName] ?? '');
|
|
187
|
+
}
|
|
188
|
+
onSearchInput() {
|
|
189
|
+
this.isItemSelected.set(false);
|
|
190
|
+
clearTimeout(this.searchTimer);
|
|
191
|
+
this.searchTimer = setTimeout(() => this.applySearch(), SEARCH_DELAY);
|
|
192
|
+
}
|
|
193
|
+
onSelectionChange(event) {
|
|
194
|
+
this.selectionChange.emit(event);
|
|
195
|
+
this.isItemSelected.set(true);
|
|
196
|
+
}
|
|
197
|
+
onValueChange(value) {
|
|
198
|
+
this.valueChange.emit(value);
|
|
199
|
+
this.isItemSelected.set(true);
|
|
200
|
+
}
|
|
201
|
+
resetSearch() {
|
|
202
|
+
this.searchForm.controls.searchTerm.setValue('');
|
|
203
|
+
this.onSearchInput();
|
|
204
|
+
}
|
|
205
|
+
applySearch() {
|
|
206
|
+
const searchTerm = (this.searchForm.value.searchTerm ?? '').trim();
|
|
207
|
+
this.items.set([]);
|
|
208
|
+
this.offset.set(0);
|
|
209
|
+
this.totalItems.set(0);
|
|
210
|
+
this.hasLoaded.set(false);
|
|
211
|
+
this.queryState = {
|
|
212
|
+
...this.queryState,
|
|
213
|
+
take: SUBSEQUENT_BATCH_SIZE,
|
|
214
|
+
skip: 0,
|
|
215
|
+
searchString: searchTerm,
|
|
216
|
+
};
|
|
217
|
+
this.fetchNextPage();
|
|
218
|
+
}
|
|
219
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: NgxMatSearchableSelectComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
220
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: NgxMatSearchableSelectComponent, isStandalone: true, selector: "ngx-mat-searchable-select", inputs: { parentForm: { classPropertyName: "parentForm", publicName: "parentForm", isSignal: true, isRequired: true, transformFunction: null }, config: { classPropertyName: "config", publicName: "config", isSignal: true, isRequired: true, transformFunction: null }, staticItems: { classPropertyName: "staticItems", publicName: "staticItems", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { selectionChange: "selectionChange", valueChange: "valueChange" }, ngImport: i0, template: "<mat-form-field style=\"width: 100%\" [formGroup]=\"parentForm()\">\r\n <mat-label>{{ config().option.label }}</mat-label>\r\n\r\n <mat-select\r\n matSelectInfiniteScroll\r\n (infiniteScroll)=\"fetchNextPage()\"\r\n [required]=\"config().option.isRequired\"\r\n [complete]=\"isScrollComplete()\"\r\n [formControlName]=\"config().option.formControlName\"\r\n (selectionChange)=\"onSelectionChange($event)\"\r\n (valueChange)=\"onValueChange($event)\"\r\n (closed)=\"onPanelClosed()\"\r\n [multiple]=\"config().multiple ?? false\"\r\n >\r\n @if (config().searchable !== false) {\r\n <div style=\"padding: 8px 16px 0; position: sticky; top: 0; z-index: 1;\" [formGroup]=\"searchForm\">\r\n <mat-form-field style=\"width: 100%\">\r\n <mat-icon matPrefix style=\"margin-right: 8px; color: rgba(0,0,0,.54)\">search</mat-icon>\r\n <input\r\n matInput\r\n formControlName=\"searchTerm\"\r\n (input)=\"onSearchInput()\"\r\n placeholder=\"Type to search...\"\r\n autocomplete=\"off\"\r\n style=\"padding: 4px 0;\"\r\n />\r\n @if (searchForm.get('searchTerm')?.value) {\r\n <button\r\n mat-icon-button\r\n matSuffix\r\n aria-label=\"Clear\"\r\n (click)=\"resetSearch()\"\r\n >\r\n <mat-icon>close</mat-icon>\r\n </button>\r\n }\r\n </mat-form-field>\r\n </div>\r\n }\r\n\r\n @if (showEditOption()) {\r\n <mat-option [value]=\"config().option.currentId\">\r\n {{ config().option.currentLabel }}\r\n </mat-option>\r\n }\r\n\r\n @for (item of items(); track item['id']) {\r\n <mat-option [value]=\"item['id']\">\r\n {{ getDisplayLabel(item) }}\r\n </mat-option>\r\n }\r\n\r\n @if (isLoading()) {\r\n <mat-option disabled>Loading...</mat-option>\r\n }\r\n\r\n @if (noItemsFound()) {\r\n <mat-option disabled>\r\n <span style=\"color: rgba(0,0,0,.54); font-style: italic;\">No items found</span>\r\n </mat-option>\r\n }\r\n </mat-select>\r\n\r\n @if (config().option.svgIcon) {\r\n <mat-icon class=\"icon-size-5 text-primary\" matPrefix [svgIcon]=\"config().option.svgIcon!\"></mat-icon>\r\n } @else if (config().option.fontIcon) {\r\n <mat-icon class=\"icon-size-5 text-primary\" matPrefix>{{ config().option.fontIcon }}</mat-icon>\r\n }\r\n\r\n @if (parentForm().get(config().option.formControlName)?.hasError('required')) {\r\n <mat-error>\r\n {{ config().option.label }} is required\r\n </mat-error>\r\n }\r\n</mat-form-field>\r\n", dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$1.RequiredValidator, selector: ":not([type=checkbox])[required][formControlName],:not([type=checkbox])[required][formControl],:not([type=checkbox])[required][ngModel]", inputs: ["required"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i1$1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i2.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i2.MatLabel, selector: "mat-label" }, { kind: "directive", type: i2.MatError, selector: "mat-error, [matError]", inputs: ["id"] }, { kind: "directive", type: i2.MatPrefix, selector: "[matPrefix], [matIconPrefix], [matTextPrefix]", inputs: ["matTextPrefix"] }, { kind: "directive", type: i2.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "ngmodule", type: MatSelectModule }, { kind: "component", type: i1.MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth", "canSelectNullableOptions"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: i1.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i4.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i5.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i6.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: MatSelectInfiniteScrollDirective, selector: "mat-select[matSelectInfiniteScroll]", inputs: ["complete", "threshold"], outputs: ["infiniteScroll"] }] }); }
|
|
221
|
+
}
|
|
222
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: NgxMatSearchableSelectComponent, decorators: [{
|
|
223
|
+
type: Component,
|
|
224
|
+
args: [{ selector: 'ngx-mat-searchable-select', standalone: true, imports: [
|
|
225
|
+
FormsModule,
|
|
226
|
+
ReactiveFormsModule,
|
|
227
|
+
MatFormFieldModule,
|
|
228
|
+
MatSelectModule,
|
|
229
|
+
MatInputModule,
|
|
230
|
+
MatButtonModule,
|
|
231
|
+
MatIconModule,
|
|
232
|
+
MatSelectInfiniteScrollDirective,
|
|
233
|
+
], template: "<mat-form-field style=\"width: 100%\" [formGroup]=\"parentForm()\">\r\n <mat-label>{{ config().option.label }}</mat-label>\r\n\r\n <mat-select\r\n matSelectInfiniteScroll\r\n (infiniteScroll)=\"fetchNextPage()\"\r\n [required]=\"config().option.isRequired\"\r\n [complete]=\"isScrollComplete()\"\r\n [formControlName]=\"config().option.formControlName\"\r\n (selectionChange)=\"onSelectionChange($event)\"\r\n (valueChange)=\"onValueChange($event)\"\r\n (closed)=\"onPanelClosed()\"\r\n [multiple]=\"config().multiple ?? false\"\r\n >\r\n @if (config().searchable !== false) {\r\n <div style=\"padding: 8px 16px 0; position: sticky; top: 0; z-index: 1;\" [formGroup]=\"searchForm\">\r\n <mat-form-field style=\"width: 100%\">\r\n <mat-icon matPrefix style=\"margin-right: 8px; color: rgba(0,0,0,.54)\">search</mat-icon>\r\n <input\r\n matInput\r\n formControlName=\"searchTerm\"\r\n (input)=\"onSearchInput()\"\r\n placeholder=\"Type to search...\"\r\n autocomplete=\"off\"\r\n style=\"padding: 4px 0;\"\r\n />\r\n @if (searchForm.get('searchTerm')?.value) {\r\n <button\r\n mat-icon-button\r\n matSuffix\r\n aria-label=\"Clear\"\r\n (click)=\"resetSearch()\"\r\n >\r\n <mat-icon>close</mat-icon>\r\n </button>\r\n }\r\n </mat-form-field>\r\n </div>\r\n }\r\n\r\n @if (showEditOption()) {\r\n <mat-option [value]=\"config().option.currentId\">\r\n {{ config().option.currentLabel }}\r\n </mat-option>\r\n }\r\n\r\n @for (item of items(); track item['id']) {\r\n <mat-option [value]=\"item['id']\">\r\n {{ getDisplayLabel(item) }}\r\n </mat-option>\r\n }\r\n\r\n @if (isLoading()) {\r\n <mat-option disabled>Loading...</mat-option>\r\n }\r\n\r\n @if (noItemsFound()) {\r\n <mat-option disabled>\r\n <span style=\"color: rgba(0,0,0,.54); font-style: italic;\">No items found</span>\r\n </mat-option>\r\n }\r\n </mat-select>\r\n\r\n @if (config().option.svgIcon) {\r\n <mat-icon class=\"icon-size-5 text-primary\" matPrefix [svgIcon]=\"config().option.svgIcon!\"></mat-icon>\r\n } @else if (config().option.fontIcon) {\r\n <mat-icon class=\"icon-size-5 text-primary\" matPrefix>{{ config().option.fontIcon }}</mat-icon>\r\n }\r\n\r\n @if (parentForm().get(config().option.formControlName)?.hasError('required')) {\r\n <mat-error>\r\n {{ config().option.label }} is required\r\n </mat-error>\r\n }\r\n</mat-form-field>\r\n" }]
|
|
234
|
+
}], propDecorators: { parentForm: [{ type: i0.Input, args: [{ isSignal: true, alias: "parentForm", required: true }] }], config: [{ type: i0.Input, args: [{ isSignal: true, alias: "config", required: true }] }], staticItems: [{ type: i0.Input, args: [{ isSignal: true, alias: "staticItems", required: false }] }], selectionChange: [{ type: i0.Output, args: ["selectionChange"] }], valueChange: [{ type: i0.Output, args: ["valueChange"] }] } });
|
|
235
|
+
|
|
236
|
+
/*
|
|
237
|
+
* Public API Surface of mat-list-shared
|
|
238
|
+
*/
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Generated bundle index. Do not edit.
|
|
242
|
+
*/
|
|
243
|
+
|
|
244
|
+
export { MatSelectInfiniteScrollDirective, MockSearchableSelectDataSource, NgxMatSearchableSelectComponent };
|
|
245
|
+
//# sourceMappingURL=ngx-mat-searchable-select.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ngx-mat-searchable-select.mjs","sources":["../../../projects/ngx-mat-searchable-select/src/lib/ngx-mat-searchable-select.mock.ts","../../../projects/ngx-mat-searchable-select/src/lib/mat-select-infinite-scroll.directive.ts","../../../projects/ngx-mat-searchable-select/src/lib/ngx-mat-searchable-select.component.ts","../../../projects/ngx-mat-searchable-select/src/lib/ngx-mat-searchable-select.component.html","../../../projects/ngx-mat-searchable-select/src/public-api.ts","../../../projects/ngx-mat-searchable-select/src/ngx-mat-searchable-select.ts"],"sourcesContent":["import { Observable, of } from 'rxjs';\r\nimport { SearchableSelectDataSource, PagedRequest, PagedResponse } from './ngx-mat-searchable-select.model';\r\n\r\n/**\r\n * A ready-made data source backed by a static in-memory array.\r\n *\r\n * Use this when you want to demo or test the component without a real API:\r\n *\r\n * ```ts\r\n * const dataSource = new MockSearchableSelectDataSource([\r\n * { id: 1, name: 'Paris' },\r\n * { id: 2, name: 'London' },\r\n * ]);\r\n *\r\n * this.config = {\r\n * dataSource,\r\n * option: { ... },\r\n * mode: 'create',\r\n * };\r\n * ```\r\n *\r\n * Search is applied across all string-coercible fields of each item.\r\n * Pagination uses the `skip` / `take` values from the request.\r\n */\r\nexport class MockSearchableSelectDataSource implements SearchableSelectDataSource {\r\n constructor(private readonly items: Record<string, unknown>[]) {}\r\n\r\n getAll(request: PagedRequest): Observable<PagedResponse<Record<string, unknown>>> {\r\n const term = request.searchString?.toLowerCase() ?? '';\r\n\r\n const filtered = term\r\n ? this.items.filter(item =>\r\n Object.values(item).some(value =>\r\n String(value ?? '').toLowerCase().includes(term)\r\n )\r\n )\r\n : this.items;\r\n\r\n const page = filtered.slice(request.skip, request.skip + request.take);\r\n\r\n return of({ data: page, totalCount: filtered.length });\r\n }\r\n}\r\n","import { Directive, EventEmitter, Input, OnDestroy, Output, Self } from '@angular/core';\r\nimport { MatSelect } from '@angular/material/select';\r\nimport { Subscription } from 'rxjs';\r\n\r\n@Directive({\r\n selector: 'mat-select[matSelectInfiniteScroll]',\r\n standalone: true,\r\n})\r\nexport class MatSelectInfiniteScrollDirective implements OnDestroy {\r\n @Input() complete = false;\r\n @Input() threshold = 0.8;\r\n @Output() infiniteScroll = new EventEmitter<void>();\r\n\r\n private subscription = new Subscription();\r\n private boundOnScroll = this.onScroll.bind(this);\r\n\r\n constructor(@Self() private matSelect: MatSelect) {\r\n this.subscription.add(\r\n this.matSelect.openedChange.subscribe((isOpen: boolean) => {\r\n if (isOpen) {\r\n setTimeout(() => this.attachScrollListener());\r\n } else {\r\n this.detachScrollListener();\r\n }\r\n })\r\n );\r\n }\r\n\r\n private attachScrollListener(): void {\r\n const panel = this.matSelect.panel?.nativeElement as HTMLElement | undefined;\r\n panel?.addEventListener('scroll', this.boundOnScroll);\r\n }\r\n\r\n private detachScrollListener(): void {\r\n const panel = this.matSelect.panel?.nativeElement as HTMLElement | undefined;\r\n panel?.removeEventListener('scroll', this.boundOnScroll);\r\n }\r\n\r\n private onScroll(event: Event): void {\r\n if (this.complete) return;\r\n const el = event.target as HTMLElement;\r\n const scrollRatio = (el.scrollTop + el.clientHeight) / el.scrollHeight;\r\n if (scrollRatio >= this.threshold) {\r\n this.infiniteScroll.emit();\r\n }\r\n }\r\n\r\n ngOnDestroy(): void {\r\n this.detachScrollListener();\r\n this.subscription.unsubscribe();\r\n }\r\n}\r\n","import {\r\n Component,\r\n DestroyRef,\r\n OnInit,\r\n computed,\r\n inject,\r\n input,\r\n output,\r\n signal,\r\n} from '@angular/core';\r\nimport { takeUntilDestroyed } from '@angular/core/rxjs-interop';\r\nimport { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms';\r\nimport { MatFormFieldModule } from '@angular/material/form-field';\r\nimport { MatSelectModule, MatSelectChange } from '@angular/material/select';\r\nimport { MatInputModule } from '@angular/material/input';\r\nimport { MatButtonModule } from '@angular/material/button';\r\nimport { MatIconModule } from '@angular/material/icon';\r\nimport { SearchableSelectConfig, SearchableSelectDataSource, PagedRequest } from './ngx-mat-searchable-select.model';\r\nimport { MockSearchableSelectDataSource } from './ngx-mat-searchable-select.mock';\r\nimport { MatSelectInfiniteScrollDirective } from './mat-select-infinite-scroll.directive';\r\n\r\nconst SEARCH_DELAY = 1000;\r\nconst INITIAL_BATCH_SIZE = 5;\r\nconst SUBSEQUENT_BATCH_SIZE = 10;\r\n\r\n@Component({\r\n selector: 'ngx-mat-searchable-select',\r\n templateUrl: 'ngx-mat-searchable-select.component.html',\r\n standalone: true,\r\n imports: [\r\n FormsModule,\r\n ReactiveFormsModule,\r\n MatFormFieldModule,\r\n MatSelectModule,\r\n MatInputModule,\r\n MatButtonModule,\r\n MatIconModule,\r\n MatSelectInfiniteScrollDirective,\r\n ],\r\n})\r\nexport class NgxMatSearchableSelectComponent implements OnInit {\r\n private readonly destroyRef = inject(DestroyRef);\r\n\r\n // Signal-based inputs\r\n readonly parentForm = input.required<FormGroup>();\r\n readonly config = input.required<SearchableSelectConfig>();\r\n /** Optional static item list. When provided, no dataSource or server call is needed. */\r\n readonly staticItems = input<Record<string, unknown>[]>();\r\n\r\n // Signal-based outputs\r\n readonly selectionChange = output<MatSelectChange>();\r\n readonly valueChange = output<unknown>();\r\n\r\n // Internal search form (inline inject — no constructor needed)\r\n readonly searchForm = inject(FormBuilder).group({ searchTerm: '' });\r\n\r\n // State signals\r\n readonly items = signal<Record<string, unknown>[]>([]);\r\n readonly isLoading = signal(false);\r\n\r\n private readonly offset = signal(0);\r\n private readonly totalItems = signal(0);\r\n private readonly hasLoaded = signal(false);\r\n private readonly isItemSelected = signal(false);\r\n private searchTimer: ReturnType<typeof setTimeout> | undefined;\r\n private queryState!: PagedRequest;\r\n private dataSource!: SearchableSelectDataSource;\r\n\r\n // Computed: true when all pages have been loaded\r\n readonly isScrollComplete = computed(\r\n () => this.totalItems() > 0 && this.offset() >= this.totalItems()\r\n );\r\n\r\n // Computed: true when a fetch completed but returned zero results\r\n readonly noItemsFound = computed(\r\n () => this.hasLoaded() && !this.isLoading() && this.items().length === 0\r\n );\r\n\r\n // Computed: show the pre-selected edit option when it is not in the loaded pages\r\n readonly showEditOption = computed(() => {\r\n const cfg = this.config();\r\n if (cfg.mode !== 'edit') return false;\r\n if (!cfg.option.currentLabel) return false;\r\n return !this.items().some(item => item['id'] === cfg.option.currentId);\r\n });\r\n\r\n ngOnInit(): void {\r\n const staticData = this.staticItems();\r\n this.dataSource = staticData\r\n ? new MockSearchableSelectDataSource(staticData)\r\n : this.config().dataSource!;\r\n\r\n const filter = this.config().filter;\r\n this.queryState = {\r\n take: INITIAL_BATCH_SIZE,\r\n skip: 0,\r\n searchString: '',\r\n ...(filter?.id !== undefined ? { id: filter.id } : {}),\r\n };\r\n this.fetchNextPage();\r\n }\r\n\r\n fetchNextPage(): void {\r\n if (this.isLoading() || this.isScrollComplete()) return;\r\n\r\n this.isLoading.set(true);\r\n this.dataSource.getAll(this.queryState)\r\n .pipe(takeUntilDestroyed(this.destroyRef))\r\n .subscribe(response => {\r\n this.items.update(prev => [...prev, ...response.data]);\r\n this.totalItems.set(response.totalCount);\r\n this.offset.update(n => n + response.data.length);\r\n this.queryState = {\r\n ...this.queryState,\r\n take: SUBSEQUENT_BATCH_SIZE,\r\n skip: this.offset(),\r\n };\r\n this.hasLoaded.set(true);\r\n this.isLoading.set(false);\r\n });\r\n }\r\n\r\n onPanelClosed(): void {\r\n if (!this.isItemSelected()) {\r\n this.resetSearch();\r\n }\r\n }\r\n\r\n getDisplayLabel(item: Record<string, unknown>): string {\r\n return String(item[this.config().option.displayName] ?? '');\r\n }\r\n\r\n onSearchInput(): void {\r\n this.isItemSelected.set(false);\r\n clearTimeout(this.searchTimer);\r\n this.searchTimer = setTimeout(() => this.applySearch(), SEARCH_DELAY);\r\n }\r\n\r\n onSelectionChange(event: MatSelectChange): void {\r\n this.selectionChange.emit(event);\r\n this.isItemSelected.set(true);\r\n }\r\n\r\n onValueChange(value: unknown): void {\r\n this.valueChange.emit(value);\r\n this.isItemSelected.set(true);\r\n }\r\n\r\n resetSearch(): void {\r\n this.searchForm.controls.searchTerm.setValue('');\r\n this.onSearchInput();\r\n }\r\n\r\n private applySearch(): void {\r\n const searchTerm = (this.searchForm.value.searchTerm ?? '').trim();\r\n this.items.set([]);\r\n this.offset.set(0);\r\n this.totalItems.set(0);\r\n this.hasLoaded.set(false);\r\n this.queryState = {\r\n ...this.queryState,\r\n take: SUBSEQUENT_BATCH_SIZE,\r\n skip: 0,\r\n searchString: searchTerm,\r\n };\r\n this.fetchNextPage();\r\n }\r\n}\r\n","<mat-form-field style=\"width: 100%\" [formGroup]=\"parentForm()\">\r\n <mat-label>{{ config().option.label }}</mat-label>\r\n\r\n <mat-select\r\n matSelectInfiniteScroll\r\n (infiniteScroll)=\"fetchNextPage()\"\r\n [required]=\"config().option.isRequired\"\r\n [complete]=\"isScrollComplete()\"\r\n [formControlName]=\"config().option.formControlName\"\r\n (selectionChange)=\"onSelectionChange($event)\"\r\n (valueChange)=\"onValueChange($event)\"\r\n (closed)=\"onPanelClosed()\"\r\n [multiple]=\"config().multiple ?? false\"\r\n >\r\n @if (config().searchable !== false) {\r\n <div style=\"padding: 8px 16px 0; position: sticky; top: 0; z-index: 1;\" [formGroup]=\"searchForm\">\r\n <mat-form-field style=\"width: 100%\">\r\n <mat-icon matPrefix style=\"margin-right: 8px; color: rgba(0,0,0,.54)\">search</mat-icon>\r\n <input\r\n matInput\r\n formControlName=\"searchTerm\"\r\n (input)=\"onSearchInput()\"\r\n placeholder=\"Type to search...\"\r\n autocomplete=\"off\"\r\n style=\"padding: 4px 0;\"\r\n />\r\n @if (searchForm.get('searchTerm')?.value) {\r\n <button\r\n mat-icon-button\r\n matSuffix\r\n aria-label=\"Clear\"\r\n (click)=\"resetSearch()\"\r\n >\r\n <mat-icon>close</mat-icon>\r\n </button>\r\n }\r\n </mat-form-field>\r\n </div>\r\n }\r\n\r\n @if (showEditOption()) {\r\n <mat-option [value]=\"config().option.currentId\">\r\n {{ config().option.currentLabel }}\r\n </mat-option>\r\n }\r\n\r\n @for (item of items(); track item['id']) {\r\n <mat-option [value]=\"item['id']\">\r\n {{ getDisplayLabel(item) }}\r\n </mat-option>\r\n }\r\n\r\n @if (isLoading()) {\r\n <mat-option disabled>Loading...</mat-option>\r\n }\r\n\r\n @if (noItemsFound()) {\r\n <mat-option disabled>\r\n <span style=\"color: rgba(0,0,0,.54); font-style: italic;\">No items found</span>\r\n </mat-option>\r\n }\r\n </mat-select>\r\n\r\n @if (config().option.svgIcon) {\r\n <mat-icon class=\"icon-size-5 text-primary\" matPrefix [svgIcon]=\"config().option.svgIcon!\"></mat-icon>\r\n } @else if (config().option.fontIcon) {\r\n <mat-icon class=\"icon-size-5 text-primary\" matPrefix>{{ config().option.fontIcon }}</mat-icon>\r\n }\r\n\r\n @if (parentForm().get(config().option.formControlName)?.hasError('required')) {\r\n <mat-error>\r\n {{ config().option.label }} is required\r\n </mat-error>\r\n }\r\n</mat-form-field>\r\n","/*\r\n * Public API Surface of mat-list-shared\r\n */\r\n\r\nexport * from './lib/ngx-mat-searchable-select.component';\r\nexport * from './lib/mat-select-infinite-scroll.directive';\r\nexport * from './lib/ngx-mat-searchable-select.model';\r\nexport * from './lib/ngx-mat-searchable-select.mock';\r\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './public-api';\n"],"names":["i1"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,12 +1,27 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "ngx-mat-searchable-select",
|
|
3
|
-
"version": "2.0.
|
|
4
|
-
"license": "MIT",
|
|
5
|
-
"peerDependencies": {
|
|
6
|
-
"@angular/common": "^21.1.3",
|
|
7
|
-
"@angular/core": "^21.1.3",
|
|
8
|
-
"@angular/forms": "^21.1.3",
|
|
9
|
-
"@angular/material": "^21.1.3",
|
|
10
|
-
"rxjs": "~7.8.0"
|
|
11
|
-
}
|
|
12
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "ngx-mat-searchable-select",
|
|
3
|
+
"version": "2.0.4",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"peerDependencies": {
|
|
6
|
+
"@angular/common": "^21.1.3",
|
|
7
|
+
"@angular/core": "^21.1.3",
|
|
8
|
+
"@angular/forms": "^21.1.3",
|
|
9
|
+
"@angular/material": "^21.1.3",
|
|
10
|
+
"rxjs": "~7.8.0"
|
|
11
|
+
},
|
|
12
|
+
"module": "fesm2022/ngx-mat-searchable-select.mjs",
|
|
13
|
+
"typings": "types/ngx-mat-searchable-select.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
"./package.json": {
|
|
16
|
+
"default": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./types/ngx-mat-searchable-select.d.ts",
|
|
20
|
+
"default": "./fesm2022/ngx-mat-searchable-select.mjs"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"tslib": "^2.3.0"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import * as _angular_forms from '@angular/forms';
|
|
2
|
+
import { FormGroup } from '@angular/forms';
|
|
3
|
+
import * as _angular_core from '@angular/core';
|
|
4
|
+
import { OnInit, OnDestroy, EventEmitter } from '@angular/core';
|
|
5
|
+
import { MatSelectChange, MatSelect } from '@angular/material/select';
|
|
6
|
+
import { Observable } from 'rxjs';
|
|
7
|
+
|
|
8
|
+
interface SearchableSelectConfig {
|
|
9
|
+
dataSource?: SearchableSelectDataSource;
|
|
10
|
+
option: SearchableSelectOption;
|
|
11
|
+
mode: 'create' | 'edit';
|
|
12
|
+
filter?: {
|
|
13
|
+
id?: number;
|
|
14
|
+
};
|
|
15
|
+
searchable?: boolean;
|
|
16
|
+
multiple?: boolean;
|
|
17
|
+
}
|
|
18
|
+
interface SearchableSelectDataSource {
|
|
19
|
+
getAll(request: PagedRequest): Observable<PagedResponse<Record<string, unknown>>>;
|
|
20
|
+
}
|
|
21
|
+
interface SearchableSelectOption {
|
|
22
|
+
isRequired: boolean;
|
|
23
|
+
displayName: string;
|
|
24
|
+
formControlName: string;
|
|
25
|
+
label: string;
|
|
26
|
+
/** Material SVG icon name (requires MatIconRegistry setup). */
|
|
27
|
+
svgIcon?: string;
|
|
28
|
+
/** Material font icon name — used when svgIcon is not provided. */
|
|
29
|
+
fontIcon?: string;
|
|
30
|
+
currentId?: number;
|
|
31
|
+
currentLabel?: string;
|
|
32
|
+
}
|
|
33
|
+
interface PagedRequest {
|
|
34
|
+
skip: number;
|
|
35
|
+
take: number;
|
|
36
|
+
sort?: string;
|
|
37
|
+
searchString: string;
|
|
38
|
+
id?: number;
|
|
39
|
+
}
|
|
40
|
+
interface PagedResponse<T> {
|
|
41
|
+
data: T[];
|
|
42
|
+
totalCount: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
declare class NgxMatSearchableSelectComponent implements OnInit {
|
|
46
|
+
private readonly destroyRef;
|
|
47
|
+
readonly parentForm: _angular_core.InputSignal<FormGroup<any>>;
|
|
48
|
+
readonly config: _angular_core.InputSignal<SearchableSelectConfig>;
|
|
49
|
+
/** Optional static item list. When provided, no dataSource or server call is needed. */
|
|
50
|
+
readonly staticItems: _angular_core.InputSignal<Record<string, unknown>[] | undefined>;
|
|
51
|
+
readonly selectionChange: _angular_core.OutputEmitterRef<MatSelectChange<any>>;
|
|
52
|
+
readonly valueChange: _angular_core.OutputEmitterRef<unknown>;
|
|
53
|
+
readonly searchForm: FormGroup<{
|
|
54
|
+
searchTerm: _angular_forms.FormControl<string | null>;
|
|
55
|
+
}>;
|
|
56
|
+
readonly items: _angular_core.WritableSignal<Record<string, unknown>[]>;
|
|
57
|
+
readonly isLoading: _angular_core.WritableSignal<boolean>;
|
|
58
|
+
private readonly offset;
|
|
59
|
+
private readonly totalItems;
|
|
60
|
+
private readonly hasLoaded;
|
|
61
|
+
private readonly isItemSelected;
|
|
62
|
+
private searchTimer;
|
|
63
|
+
private queryState;
|
|
64
|
+
private dataSource;
|
|
65
|
+
readonly isScrollComplete: _angular_core.Signal<boolean>;
|
|
66
|
+
readonly noItemsFound: _angular_core.Signal<boolean>;
|
|
67
|
+
readonly showEditOption: _angular_core.Signal<boolean>;
|
|
68
|
+
ngOnInit(): void;
|
|
69
|
+
fetchNextPage(): void;
|
|
70
|
+
onPanelClosed(): void;
|
|
71
|
+
getDisplayLabel(item: Record<string, unknown>): string;
|
|
72
|
+
onSearchInput(): void;
|
|
73
|
+
onSelectionChange(event: MatSelectChange): void;
|
|
74
|
+
onValueChange(value: unknown): void;
|
|
75
|
+
resetSearch(): void;
|
|
76
|
+
private applySearch;
|
|
77
|
+
static ɵfac: _angular_core.ɵɵFactoryDeclaration<NgxMatSearchableSelectComponent, never>;
|
|
78
|
+
static ɵcmp: _angular_core.ɵɵComponentDeclaration<NgxMatSearchableSelectComponent, "ngx-mat-searchable-select", never, { "parentForm": { "alias": "parentForm"; "required": true; "isSignal": true; }; "config": { "alias": "config"; "required": true; "isSignal": true; }; "staticItems": { "alias": "staticItems"; "required": false; "isSignal": true; }; }, { "selectionChange": "selectionChange"; "valueChange": "valueChange"; }, never, never, true, never>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
declare class MatSelectInfiniteScrollDirective implements OnDestroy {
|
|
82
|
+
private matSelect;
|
|
83
|
+
complete: boolean;
|
|
84
|
+
threshold: number;
|
|
85
|
+
infiniteScroll: EventEmitter<void>;
|
|
86
|
+
private subscription;
|
|
87
|
+
private boundOnScroll;
|
|
88
|
+
constructor(matSelect: MatSelect);
|
|
89
|
+
private attachScrollListener;
|
|
90
|
+
private detachScrollListener;
|
|
91
|
+
private onScroll;
|
|
92
|
+
ngOnDestroy(): void;
|
|
93
|
+
static ɵfac: _angular_core.ɵɵFactoryDeclaration<MatSelectInfiniteScrollDirective, [{ self: true; }]>;
|
|
94
|
+
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<MatSelectInfiniteScrollDirective, "mat-select[matSelectInfiniteScroll]", never, { "complete": { "alias": "complete"; "required": false; }; "threshold": { "alias": "threshold"; "required": false; }; }, { "infiniteScroll": "infiniteScroll"; }, never, never, true, never>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A ready-made data source backed by a static in-memory array.
|
|
99
|
+
*
|
|
100
|
+
* Use this when you want to demo or test the component without a real API:
|
|
101
|
+
*
|
|
102
|
+
* ```ts
|
|
103
|
+
* const dataSource = new MockSearchableSelectDataSource([
|
|
104
|
+
* { id: 1, name: 'Paris' },
|
|
105
|
+
* { id: 2, name: 'London' },
|
|
106
|
+
* ]);
|
|
107
|
+
*
|
|
108
|
+
* this.config = {
|
|
109
|
+
* dataSource,
|
|
110
|
+
* option: { ... },
|
|
111
|
+
* mode: 'create',
|
|
112
|
+
* };
|
|
113
|
+
* ```
|
|
114
|
+
*
|
|
115
|
+
* Search is applied across all string-coercible fields of each item.
|
|
116
|
+
* Pagination uses the `skip` / `take` values from the request.
|
|
117
|
+
*/
|
|
118
|
+
declare class MockSearchableSelectDataSource implements SearchableSelectDataSource {
|
|
119
|
+
private readonly items;
|
|
120
|
+
constructor(items: Record<string, unknown>[]);
|
|
121
|
+
getAll(request: PagedRequest): Observable<PagedResponse<Record<string, unknown>>>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export { MatSelectInfiniteScrollDirective, MockSearchableSelectDataSource, NgxMatSearchableSelectComponent };
|
|
125
|
+
export type { PagedRequest, PagedResponse, SearchableSelectConfig, SearchableSelectDataSource, SearchableSelectOption };
|
package/karma.conf.js
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
// Karma configuration file, see link for more information
|
|
2
|
-
// https://karma-runner.github.io/1.0/config/configuration-file.html
|
|
3
|
-
|
|
4
|
-
module.exports = function (config) {
|
|
5
|
-
config.set({
|
|
6
|
-
basePath: '',
|
|
7
|
-
frameworks: ['jasmine', '@angular-devkit/build-angular'],
|
|
8
|
-
plugins: [
|
|
9
|
-
require('karma-jasmine'),
|
|
10
|
-
require('karma-chrome-launcher'),
|
|
11
|
-
require('karma-jasmine-html-reporter'),
|
|
12
|
-
require('karma-coverage'),
|
|
13
|
-
require('@angular-devkit/build-angular/plugins/karma')
|
|
14
|
-
],
|
|
15
|
-
client: {
|
|
16
|
-
jasmine: {
|
|
17
|
-
// you can add configuration options for Jasmine here
|
|
18
|
-
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
|
|
19
|
-
// for example, you can disable the random execution with `random: false`
|
|
20
|
-
// or set a specific seed with `seed: 4321`
|
|
21
|
-
},
|
|
22
|
-
clearContext: false // leave Jasmine Spec Runner output visible in browser
|
|
23
|
-
},
|
|
24
|
-
jasmineHtmlReporter: {
|
|
25
|
-
suppressAll: true // removes the duplicated traces
|
|
26
|
-
},
|
|
27
|
-
coverageReporter: {
|
|
28
|
-
dir: require('path').join(__dirname, '../../coverage/mat-list-shared'),
|
|
29
|
-
subdir: '.',
|
|
30
|
-
reporters: [
|
|
31
|
-
{ type: 'html' },
|
|
32
|
-
{ type: 'text-summary' }
|
|
33
|
-
]
|
|
34
|
-
},
|
|
35
|
-
reporters: ['progress', 'kjhtml'],
|
|
36
|
-
port: 9876,
|
|
37
|
-
colors: true,
|
|
38
|
-
logLevel: config.LOG_INFO,
|
|
39
|
-
autoWatch: true,
|
|
40
|
-
browsers: ['Chrome'],
|
|
41
|
-
singleRun: false,
|
|
42
|
-
restartOnFileChange: true
|
|
43
|
-
});
|
|
44
|
-
};
|
package/ng-package.json
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
<mat-form-field style="width: 100%" [formGroup]="parentForm()">
|
|
2
|
-
<mat-label>{{ config().option.label }}</mat-label>
|
|
3
|
-
|
|
4
|
-
<mat-select
|
|
5
|
-
matSelectInfiniteScroll
|
|
6
|
-
(infiniteScroll)="fetchNextPage()"
|
|
7
|
-
[required]="config().option.isRequired"
|
|
8
|
-
[complete]="isScrollComplete()"
|
|
9
|
-
[formControlName]="config().option.formControlName"
|
|
10
|
-
(selectionChange)="onSelectionChange($event)"
|
|
11
|
-
(valueChange)="onValueChange($event)"
|
|
12
|
-
(closed)="onPanelClosed()"
|
|
13
|
-
[multiple]="config().multiple ?? false"
|
|
14
|
-
>
|
|
15
|
-
@if (config().searchable !== false) {
|
|
16
|
-
<div style="padding: 8px 16px 0; position: sticky; top: 0; z-index: 1;" [formGroup]="searchForm">
|
|
17
|
-
<mat-form-field style="width: 100%">
|
|
18
|
-
<mat-icon matPrefix style="margin-right: 8px; color: rgba(0,0,0,.54)">search</mat-icon>
|
|
19
|
-
<input
|
|
20
|
-
matInput
|
|
21
|
-
formControlName="searchTerm"
|
|
22
|
-
(input)="onSearchInput()"
|
|
23
|
-
placeholder="Type to search..."
|
|
24
|
-
autocomplete="off"
|
|
25
|
-
style="padding: 4px 0;"
|
|
26
|
-
/>
|
|
27
|
-
@if (searchForm.get('searchTerm')?.value) {
|
|
28
|
-
<button
|
|
29
|
-
mat-icon-button
|
|
30
|
-
matSuffix
|
|
31
|
-
aria-label="Clear"
|
|
32
|
-
(click)="resetSearch()"
|
|
33
|
-
>
|
|
34
|
-
<mat-icon>close</mat-icon>
|
|
35
|
-
</button>
|
|
36
|
-
}
|
|
37
|
-
</mat-form-field>
|
|
38
|
-
</div>
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
@if (showEditOption()) {
|
|
42
|
-
<mat-option [value]="config().option.currentId">
|
|
43
|
-
{{ config().option.currentLabel }}
|
|
44
|
-
</mat-option>
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
@for (item of items(); track item['id']) {
|
|
48
|
-
<mat-option [value]="item['id']">
|
|
49
|
-
{{ getDisplayLabel(item) }}
|
|
50
|
-
</mat-option>
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
@if (isLoading()) {
|
|
54
|
-
<mat-option disabled>Loading...</mat-option>
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
@if (noItemsFound()) {
|
|
58
|
-
<mat-option disabled>
|
|
59
|
-
<span style="color: rgba(0,0,0,.54); font-style: italic;">No items found</span>
|
|
60
|
-
</mat-option>
|
|
61
|
-
}
|
|
62
|
-
</mat-select>
|
|
63
|
-
|
|
64
|
-
@if (config().option.svgIcon) {
|
|
65
|
-
<mat-icon class="icon-size-5 text-primary" matPrefix [svgIcon]="config().option.svgIcon!"></mat-icon>
|
|
66
|
-
} @else if (config().option.fontIcon) {
|
|
67
|
-
<mat-icon class="icon-size-5 text-primary" matPrefix>{{ config().option.fontIcon }}</mat-icon>
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
@if (parentForm().get(config().option.formControlName)?.hasError('required')) {
|
|
71
|
-
<mat-error>
|
|
72
|
-
{{ config().option.label }} is required
|
|
73
|
-
</mat-error>
|
|
74
|
-
}
|
|
75
|
-
</mat-form-field>
|
|
@@ -1,229 +0,0 @@
|
|
|
1
|
-
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
|
2
|
-
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
3
|
-
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
|
4
|
-
import { provideZoneChangeDetection } from '@angular/core';
|
|
5
|
-
|
|
6
|
-
import { NgxMatSearchableSelectComponent } from './mat-list-shared.component';
|
|
7
|
-
import { MockSearchableSelectDataSource } from './mat-list-shared.mock';
|
|
8
|
-
import { SearchableSelectConfig } from './mat-list-shared.model';
|
|
9
|
-
|
|
10
|
-
const MOCK_CITIES: Record<string, unknown>[] = [
|
|
11
|
-
{ id: 1, name: 'Paris' },
|
|
12
|
-
{ id: 2, name: 'London' },
|
|
13
|
-
{ id: 3, name: 'Berlin' },
|
|
14
|
-
{ id: 4, name: 'Madrid' },
|
|
15
|
-
{ id: 5, name: 'Rome' },
|
|
16
|
-
{ id: 6, name: 'Vienna' },
|
|
17
|
-
{ id: 7, name: 'Warsaw' },
|
|
18
|
-
{ id: 8, name: 'Prague' },
|
|
19
|
-
];
|
|
20
|
-
|
|
21
|
-
function buildConfig(overrides: Partial<SearchableSelectConfig> = {}): SearchableSelectConfig {
|
|
22
|
-
return {
|
|
23
|
-
option: {
|
|
24
|
-
isRequired: true,
|
|
25
|
-
displayName: 'name',
|
|
26
|
-
formControlName: 'city',
|
|
27
|
-
label: 'City',
|
|
28
|
-
svgIcon: 'city-icon',
|
|
29
|
-
},
|
|
30
|
-
mode: 'create',
|
|
31
|
-
searchable: true,
|
|
32
|
-
multiple: false,
|
|
33
|
-
...overrides,
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
describe('NgxMatSearchableSelectComponent', () => {
|
|
38
|
-
let fixture: ComponentFixture<NgxMatSearchableSelectComponent>;
|
|
39
|
-
let component: NgxMatSearchableSelectComponent;
|
|
40
|
-
const fb = new FormBuilder();
|
|
41
|
-
|
|
42
|
-
function createFixture(config: SearchableSelectConfig, staticItems?: Record<string, unknown>[]) {
|
|
43
|
-
fixture = TestBed.createComponent(NgxMatSearchableSelectComponent);
|
|
44
|
-
component = fixture.componentInstance;
|
|
45
|
-
|
|
46
|
-
const parentForm = fb.group({ city: [null, Validators.required] });
|
|
47
|
-
|
|
48
|
-
fixture.componentRef.setInput('parentForm', parentForm);
|
|
49
|
-
fixture.componentRef.setInput('config', config);
|
|
50
|
-
if (staticItems) {
|
|
51
|
-
fixture.componentRef.setInput('staticItems', staticItems);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
fixture.detectChanges();
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
beforeEach(async () => {
|
|
58
|
-
await TestBed.configureTestingModule({
|
|
59
|
-
imports: [NgxMatSearchableSelectComponent, ReactiveFormsModule, NoopAnimationsModule],
|
|
60
|
-
providers: [provideZoneChangeDetection()],
|
|
61
|
-
}).compileComponents();
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
describe('with staticItems', () => {
|
|
65
|
-
beforeEach(() => {
|
|
66
|
-
createFixture(buildConfig(), MOCK_CITIES);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it('should create the component', () => {
|
|
70
|
-
expect(component).toBeTruthy();
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it('should load the first page from staticItems on init', () => {
|
|
74
|
-
expect(component.items().length).toBeGreaterThan(0);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('should not show a loading indicator after items are loaded', () => {
|
|
78
|
-
expect(component.isLoading()).toBeFalse();
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('isScrollComplete should be false when not all items are loaded', () => {
|
|
82
|
-
expect(component.isScrollComplete()).toBeFalse();
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
describe('with MockSearchableSelectDataSource', () => {
|
|
87
|
-
beforeEach(() => {
|
|
88
|
-
const config = buildConfig({ dataSource: new MockSearchableSelectDataSource(MOCK_CITIES) });
|
|
89
|
-
createFixture(config);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('should create the component', () => {
|
|
93
|
-
expect(component).toBeTruthy();
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('should load the initial page on init', () => {
|
|
97
|
-
expect(component.items().length).toBe(5);
|
|
98
|
-
expect(component.isLoading()).toBeFalse();
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it('should accumulate items on fetchNextPage', () => {
|
|
102
|
-
component.fetchNextPage();
|
|
103
|
-
fixture.detectChanges();
|
|
104
|
-
expect(component.items().length).toBe(8);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it('isScrollComplete should be true when offset reaches totalCount', () => {
|
|
108
|
-
component.fetchNextPage();
|
|
109
|
-
fixture.detectChanges();
|
|
110
|
-
expect(component.isScrollComplete()).toBeTrue();
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('should not fetch again when isScrollComplete is true', () => {
|
|
114
|
-
component.fetchNextPage();
|
|
115
|
-
fixture.detectChanges();
|
|
116
|
-
const itemsBefore = component.items().length;
|
|
117
|
-
|
|
118
|
-
component.fetchNextPage();
|
|
119
|
-
fixture.detectChanges();
|
|
120
|
-
expect(component.items().length).toBe(itemsBefore);
|
|
121
|
-
});
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
describe('search', () => {
|
|
125
|
-
beforeEach(() => {
|
|
126
|
-
createFixture(buildConfig(), MOCK_CITIES);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('should filter items after search debounce', fakeAsync(() => {
|
|
130
|
-
component.searchForm.controls.searchTerm.setValue('par');
|
|
131
|
-
component.onSearchInput();
|
|
132
|
-
tick(1000);
|
|
133
|
-
fixture.detectChanges();
|
|
134
|
-
|
|
135
|
-
expect(component.items().length).toBe(1);
|
|
136
|
-
expect(component.items()[0]['name']).toBe('Paris');
|
|
137
|
-
}));
|
|
138
|
-
|
|
139
|
-
it('should restore all items after resetSearch', fakeAsync(() => {
|
|
140
|
-
component.searchForm.controls.searchTerm.setValue('par');
|
|
141
|
-
component.onSearchInput();
|
|
142
|
-
tick(1000);
|
|
143
|
-
fixture.detectChanges();
|
|
144
|
-
|
|
145
|
-
component.resetSearch();
|
|
146
|
-
tick(1000);
|
|
147
|
-
fixture.detectChanges();
|
|
148
|
-
|
|
149
|
-
expect(component.items().length).toBe(5);
|
|
150
|
-
}));
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
describe('showEditOption', () => {
|
|
154
|
-
it('should be false in create mode', () => {
|
|
155
|
-
createFixture(buildConfig({ mode: 'create' }), MOCK_CITIES);
|
|
156
|
-
expect(component.showEditOption()).toBeFalse();
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
it('should be false in edit mode when currentLabel is not set', () => {
|
|
160
|
-
createFixture(buildConfig({ mode: 'edit' }), MOCK_CITIES);
|
|
161
|
-
expect(component.showEditOption()).toBeFalse();
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it('should be false when currentId is already in the loaded items', () => {
|
|
165
|
-
createFixture(
|
|
166
|
-
buildConfig({
|
|
167
|
-
mode: 'edit',
|
|
168
|
-
option: {
|
|
169
|
-
isRequired: true,
|
|
170
|
-
displayName: 'name',
|
|
171
|
-
formControlName: 'city',
|
|
172
|
-
label: 'City',
|
|
173
|
-
svgIcon: 'city-icon',
|
|
174
|
-
currentId: 1,
|
|
175
|
-
currentLabel: 'Paris',
|
|
176
|
-
},
|
|
177
|
-
}),
|
|
178
|
-
MOCK_CITIES
|
|
179
|
-
);
|
|
180
|
-
expect(component.showEditOption()).toBeFalse();
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it('should be true when currentId is NOT in the loaded items', () => {
|
|
184
|
-
createFixture(
|
|
185
|
-
buildConfig({
|
|
186
|
-
mode: 'edit',
|
|
187
|
-
option: {
|
|
188
|
-
isRequired: true,
|
|
189
|
-
displayName: 'name',
|
|
190
|
-
formControlName: 'city',
|
|
191
|
-
label: 'City',
|
|
192
|
-
svgIcon: 'city-icon',
|
|
193
|
-
currentId: 99,
|
|
194
|
-
currentLabel: 'Unknown City',
|
|
195
|
-
},
|
|
196
|
-
}),
|
|
197
|
-
MOCK_CITIES
|
|
198
|
-
);
|
|
199
|
-
expect(component.showEditOption()).toBeTrue();
|
|
200
|
-
});
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
describe('outputs', () => {
|
|
204
|
-
beforeEach(() => {
|
|
205
|
-
createFixture(buildConfig(), MOCK_CITIES);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it('should emit selectionChange when onSelectionChange is called', () => {
|
|
209
|
-
const emitted: unknown[] = [];
|
|
210
|
-
component.selectionChange.subscribe(e => emitted.push(e));
|
|
211
|
-
|
|
212
|
-
const mockEvent = { value: 1 } as any;
|
|
213
|
-
component.onSelectionChange(mockEvent);
|
|
214
|
-
|
|
215
|
-
expect(emitted.length).toBe(1);
|
|
216
|
-
expect(emitted[0]).toEqual(mockEvent);
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
it('should emit valueChange when onValueChange is called', () => {
|
|
220
|
-
const emitted: unknown[] = [];
|
|
221
|
-
component.valueChange.subscribe(e => emitted.push(e));
|
|
222
|
-
|
|
223
|
-
component.onValueChange(42);
|
|
224
|
-
|
|
225
|
-
expect(emitted.length).toBe(1);
|
|
226
|
-
expect(emitted[0]).toBe(42);
|
|
227
|
-
});
|
|
228
|
-
});
|
|
229
|
-
});
|
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Component,
|
|
3
|
-
DestroyRef,
|
|
4
|
-
OnInit,
|
|
5
|
-
computed,
|
|
6
|
-
inject,
|
|
7
|
-
input,
|
|
8
|
-
output,
|
|
9
|
-
signal,
|
|
10
|
-
} from '@angular/core';
|
|
11
|
-
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
12
|
-
import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms';
|
|
13
|
-
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
14
|
-
import { MatSelectModule, MatSelectChange } from '@angular/material/select';
|
|
15
|
-
import { MatInputModule } from '@angular/material/input';
|
|
16
|
-
import { MatButtonModule } from '@angular/material/button';
|
|
17
|
-
import { MatIconModule } from '@angular/material/icon';
|
|
18
|
-
import { SearchableSelectConfig, SearchableSelectDataSource, PagedRequest } from './mat-list-shared.model';
|
|
19
|
-
import { MockSearchableSelectDataSource } from './mat-list-shared.mock';
|
|
20
|
-
import { MatSelectInfiniteScrollDirective } from './mat-select-infinite-scroll.directive';
|
|
21
|
-
|
|
22
|
-
const SEARCH_DELAY = 1000;
|
|
23
|
-
const INITIAL_BATCH_SIZE = 5;
|
|
24
|
-
const SUBSEQUENT_BATCH_SIZE = 10;
|
|
25
|
-
|
|
26
|
-
@Component({
|
|
27
|
-
selector: 'ngx-mat-searchable-select',
|
|
28
|
-
templateUrl: 'mat-list-shared.component.html',
|
|
29
|
-
standalone: true,
|
|
30
|
-
imports: [
|
|
31
|
-
FormsModule,
|
|
32
|
-
ReactiveFormsModule,
|
|
33
|
-
MatFormFieldModule,
|
|
34
|
-
MatSelectModule,
|
|
35
|
-
MatInputModule,
|
|
36
|
-
MatButtonModule,
|
|
37
|
-
MatIconModule,
|
|
38
|
-
MatSelectInfiniteScrollDirective,
|
|
39
|
-
],
|
|
40
|
-
})
|
|
41
|
-
export class NgxMatSearchableSelectComponent implements OnInit {
|
|
42
|
-
private readonly destroyRef = inject(DestroyRef);
|
|
43
|
-
|
|
44
|
-
// Signal-based inputs
|
|
45
|
-
readonly parentForm = input.required<FormGroup>();
|
|
46
|
-
readonly config = input.required<SearchableSelectConfig>();
|
|
47
|
-
/** Optional static item list. When provided, no dataSource or server call is needed. */
|
|
48
|
-
readonly staticItems = input<Record<string, unknown>[]>();
|
|
49
|
-
|
|
50
|
-
// Signal-based outputs
|
|
51
|
-
readonly selectionChange = output<MatSelectChange>();
|
|
52
|
-
readonly valueChange = output<unknown>();
|
|
53
|
-
|
|
54
|
-
// Internal search form (inline inject — no constructor needed)
|
|
55
|
-
readonly searchForm = inject(FormBuilder).group({ searchTerm: '' });
|
|
56
|
-
|
|
57
|
-
// State signals
|
|
58
|
-
readonly items = signal<Record<string, unknown>[]>([]);
|
|
59
|
-
readonly isLoading = signal(false);
|
|
60
|
-
|
|
61
|
-
private readonly offset = signal(0);
|
|
62
|
-
private readonly totalItems = signal(0);
|
|
63
|
-
private readonly hasLoaded = signal(false);
|
|
64
|
-
private readonly isItemSelected = signal(false);
|
|
65
|
-
private searchTimer: ReturnType<typeof setTimeout> | undefined;
|
|
66
|
-
private queryState!: PagedRequest;
|
|
67
|
-
private dataSource!: SearchableSelectDataSource;
|
|
68
|
-
|
|
69
|
-
// Computed: true when all pages have been loaded
|
|
70
|
-
readonly isScrollComplete = computed(
|
|
71
|
-
() => this.totalItems() > 0 && this.offset() >= this.totalItems()
|
|
72
|
-
);
|
|
73
|
-
|
|
74
|
-
// Computed: true when a fetch completed but returned zero results
|
|
75
|
-
readonly noItemsFound = computed(
|
|
76
|
-
() => this.hasLoaded() && !this.isLoading() && this.items().length === 0
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
// Computed: show the pre-selected edit option when it is not in the loaded pages
|
|
80
|
-
readonly showEditOption = computed(() => {
|
|
81
|
-
const cfg = this.config();
|
|
82
|
-
if (cfg.mode !== 'edit') return false;
|
|
83
|
-
if (!cfg.option.currentLabel) return false;
|
|
84
|
-
return !this.items().some(item => item['id'] === cfg.option.currentId);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
ngOnInit(): void {
|
|
88
|
-
const staticData = this.staticItems();
|
|
89
|
-
this.dataSource = staticData
|
|
90
|
-
? new MockSearchableSelectDataSource(staticData)
|
|
91
|
-
: this.config().dataSource!;
|
|
92
|
-
|
|
93
|
-
const filter = this.config().filter;
|
|
94
|
-
this.queryState = {
|
|
95
|
-
take: INITIAL_BATCH_SIZE,
|
|
96
|
-
skip: 0,
|
|
97
|
-
searchString: '',
|
|
98
|
-
...(filter?.id !== undefined ? { id: filter.id } : {}),
|
|
99
|
-
};
|
|
100
|
-
this.fetchNextPage();
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
fetchNextPage(): void {
|
|
104
|
-
if (this.isLoading() || this.isScrollComplete()) return;
|
|
105
|
-
|
|
106
|
-
this.isLoading.set(true);
|
|
107
|
-
this.dataSource.getAll(this.queryState)
|
|
108
|
-
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
109
|
-
.subscribe(response => {
|
|
110
|
-
this.items.update(prev => [...prev, ...response.data]);
|
|
111
|
-
this.totalItems.set(response.totalCount);
|
|
112
|
-
this.offset.update(n => n + response.data.length);
|
|
113
|
-
this.queryState = {
|
|
114
|
-
...this.queryState,
|
|
115
|
-
take: SUBSEQUENT_BATCH_SIZE,
|
|
116
|
-
skip: this.offset(),
|
|
117
|
-
};
|
|
118
|
-
this.hasLoaded.set(true);
|
|
119
|
-
this.isLoading.set(false);
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
onPanelClosed(): void {
|
|
124
|
-
if (!this.isItemSelected()) {
|
|
125
|
-
this.resetSearch();
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
getDisplayLabel(item: Record<string, unknown>): string {
|
|
130
|
-
return String(item[this.config().option.displayName] ?? '');
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
onSearchInput(): void {
|
|
134
|
-
this.isItemSelected.set(false);
|
|
135
|
-
clearTimeout(this.searchTimer);
|
|
136
|
-
this.searchTimer = setTimeout(() => this.applySearch(), SEARCH_DELAY);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
onSelectionChange(event: MatSelectChange): void {
|
|
140
|
-
this.selectionChange.emit(event);
|
|
141
|
-
this.isItemSelected.set(true);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
onValueChange(value: unknown): void {
|
|
145
|
-
this.valueChange.emit(value);
|
|
146
|
-
this.isItemSelected.set(true);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
resetSearch(): void {
|
|
150
|
-
this.searchForm.controls.searchTerm.setValue('');
|
|
151
|
-
this.onSearchInput();
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
private applySearch(): void {
|
|
155
|
-
const searchTerm = (this.searchForm.value.searchTerm ?? '').trim();
|
|
156
|
-
this.items.set([]);
|
|
157
|
-
this.offset.set(0);
|
|
158
|
-
this.totalItems.set(0);
|
|
159
|
-
this.hasLoaded.set(false);
|
|
160
|
-
this.queryState = {
|
|
161
|
-
...this.queryState,
|
|
162
|
-
take: SUBSEQUENT_BATCH_SIZE,
|
|
163
|
-
skip: 0,
|
|
164
|
-
searchString: searchTerm,
|
|
165
|
-
};
|
|
166
|
-
this.fetchNextPage();
|
|
167
|
-
}
|
|
168
|
-
}
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { Observable, of } from 'rxjs';
|
|
2
|
-
import { SearchableSelectDataSource, PagedRequest, PagedResponse } from './mat-list-shared.model';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* A ready-made data source backed by a static in-memory array.
|
|
6
|
-
*
|
|
7
|
-
* Use this when you want to demo or test the component without a real API:
|
|
8
|
-
*
|
|
9
|
-
* ```ts
|
|
10
|
-
* const dataSource = new MockSearchableSelectDataSource([
|
|
11
|
-
* { id: 1, name: 'Paris' },
|
|
12
|
-
* { id: 2, name: 'London' },
|
|
13
|
-
* ]);
|
|
14
|
-
*
|
|
15
|
-
* this.config = {
|
|
16
|
-
* dataSource,
|
|
17
|
-
* option: { ... },
|
|
18
|
-
* mode: 'create',
|
|
19
|
-
* };
|
|
20
|
-
* ```
|
|
21
|
-
*
|
|
22
|
-
* Search is applied across all string-coercible fields of each item.
|
|
23
|
-
* Pagination uses the `skip` / `take` values from the request.
|
|
24
|
-
*/
|
|
25
|
-
export class MockSearchableSelectDataSource implements SearchableSelectDataSource {
|
|
26
|
-
constructor(private readonly items: Record<string, unknown>[]) {}
|
|
27
|
-
|
|
28
|
-
getAll(request: PagedRequest): Observable<PagedResponse<Record<string, unknown>>> {
|
|
29
|
-
const term = request.searchString?.toLowerCase() ?? '';
|
|
30
|
-
|
|
31
|
-
const filtered = term
|
|
32
|
-
? this.items.filter(item =>
|
|
33
|
-
Object.values(item).some(value =>
|
|
34
|
-
String(value ?? '').toLowerCase().includes(term)
|
|
35
|
-
)
|
|
36
|
-
)
|
|
37
|
-
: this.items;
|
|
38
|
-
|
|
39
|
-
const page = filtered.slice(request.skip, request.skip + request.take);
|
|
40
|
-
|
|
41
|
-
return of({ data: page, totalCount: filtered.length });
|
|
42
|
-
}
|
|
43
|
-
}
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { Observable } from 'rxjs';
|
|
2
|
-
|
|
3
|
-
export interface SearchableSelectConfig {
|
|
4
|
-
dataSource?: SearchableSelectDataSource;
|
|
5
|
-
option: SearchableSelectOption;
|
|
6
|
-
mode: 'create' | 'edit';
|
|
7
|
-
filter?: { id?: number };
|
|
8
|
-
searchable?: boolean;
|
|
9
|
-
multiple?: boolean;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface SearchableSelectDataSource {
|
|
13
|
-
getAll(request: PagedRequest): Observable<PagedResponse<Record<string, unknown>>>;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface SearchableSelectOption {
|
|
17
|
-
isRequired: boolean;
|
|
18
|
-
displayName: string;
|
|
19
|
-
formControlName: string;
|
|
20
|
-
label: string;
|
|
21
|
-
/** Material SVG icon name (requires MatIconRegistry setup). */
|
|
22
|
-
svgIcon?: string;
|
|
23
|
-
/** Material font icon name — used when svgIcon is not provided. */
|
|
24
|
-
fontIcon?: string;
|
|
25
|
-
currentId?: number;
|
|
26
|
-
currentLabel?: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface PagedRequest {
|
|
30
|
-
skip: number;
|
|
31
|
-
take: number;
|
|
32
|
-
sort?: string;
|
|
33
|
-
searchString: string;
|
|
34
|
-
id?: number;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export interface PagedResponse<T> {
|
|
38
|
-
data: T[];
|
|
39
|
-
totalCount: number;
|
|
40
|
-
}
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { TestBed } from '@angular/core/testing';
|
|
2
|
-
|
|
3
|
-
import { MatListSharedService } from './mat-list-shared.service';
|
|
4
|
-
|
|
5
|
-
describe('MatListSharedService', () => {
|
|
6
|
-
let service: MatListSharedService;
|
|
7
|
-
|
|
8
|
-
beforeEach(() => {
|
|
9
|
-
TestBed.configureTestingModule({});
|
|
10
|
-
service = TestBed.inject(MatListSharedService);
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it('should be created', () => {
|
|
14
|
-
expect(service).toBeTruthy();
|
|
15
|
-
});
|
|
16
|
-
});
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { Directive, EventEmitter, Input, OnDestroy, Output, Self } from '@angular/core';
|
|
2
|
-
import { MatSelect } from '@angular/material/select';
|
|
3
|
-
import { Subscription } from 'rxjs';
|
|
4
|
-
|
|
5
|
-
@Directive({
|
|
6
|
-
selector: 'mat-select[matSelectInfiniteScroll]',
|
|
7
|
-
standalone: true,
|
|
8
|
-
})
|
|
9
|
-
export class MatSelectInfiniteScrollDirective implements OnDestroy {
|
|
10
|
-
@Input() complete = false;
|
|
11
|
-
@Input() threshold = 0.8;
|
|
12
|
-
@Output() infiniteScroll = new EventEmitter<void>();
|
|
13
|
-
|
|
14
|
-
private subscription = new Subscription();
|
|
15
|
-
private boundOnScroll = this.onScroll.bind(this);
|
|
16
|
-
|
|
17
|
-
constructor(@Self() private matSelect: MatSelect) {
|
|
18
|
-
this.subscription.add(
|
|
19
|
-
this.matSelect.openedChange.subscribe((isOpen: boolean) => {
|
|
20
|
-
if (isOpen) {
|
|
21
|
-
setTimeout(() => this.attachScrollListener());
|
|
22
|
-
} else {
|
|
23
|
-
this.detachScrollListener();
|
|
24
|
-
}
|
|
25
|
-
})
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
private attachScrollListener(): void {
|
|
30
|
-
const panel = this.matSelect.panel?.nativeElement as HTMLElement | undefined;
|
|
31
|
-
panel?.addEventListener('scroll', this.boundOnScroll);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
private detachScrollListener(): void {
|
|
35
|
-
const panel = this.matSelect.panel?.nativeElement as HTMLElement | undefined;
|
|
36
|
-
panel?.removeEventListener('scroll', this.boundOnScroll);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
private onScroll(event: Event): void {
|
|
40
|
-
if (this.complete) return;
|
|
41
|
-
const el = event.target as HTMLElement;
|
|
42
|
-
const scrollRatio = (el.scrollTop + el.clientHeight) / el.scrollHeight;
|
|
43
|
-
if (scrollRatio >= this.threshold) {
|
|
44
|
-
this.infiniteScroll.emit();
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
ngOnDestroy(): void {
|
|
49
|
-
this.detachScrollListener();
|
|
50
|
-
this.subscription.unsubscribe();
|
|
51
|
-
}
|
|
52
|
-
}
|
package/src/public-api.ts
DELETED
package/src/test.ts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
|
|
2
|
-
|
|
3
|
-
import 'zone.js';
|
|
4
|
-
import 'zone.js/testing';
|
|
5
|
-
import { getTestBed } from '@angular/core/testing';
|
|
6
|
-
import {
|
|
7
|
-
BrowserDynamicTestingModule,
|
|
8
|
-
platformBrowserDynamicTesting
|
|
9
|
-
} from '@angular/platform-browser-dynamic/testing';
|
|
10
|
-
|
|
11
|
-
// First, initialize the Angular testing environment.
|
|
12
|
-
getTestBed().initTestEnvironment(
|
|
13
|
-
BrowserDynamicTestingModule,
|
|
14
|
-
platformBrowserDynamicTesting(),
|
|
15
|
-
);
|
package/tsconfig.lib.json
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
|
2
|
-
{
|
|
3
|
-
"extends": "../../tsconfig.json",
|
|
4
|
-
"compilerOptions": {
|
|
5
|
-
"outDir": "../../out-tsc/lib",
|
|
6
|
-
"declaration": true,
|
|
7
|
-
"declarationMap": true,
|
|
8
|
-
"inlineSources": true,
|
|
9
|
-
"types": []
|
|
10
|
-
},
|
|
11
|
-
"exclude": [
|
|
12
|
-
"src/test.ts",
|
|
13
|
-
"**/*.spec.ts"
|
|
14
|
-
]
|
|
15
|
-
}
|
package/tsconfig.lib.prod.json
DELETED
package/tsconfig.spec.json
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
|
2
|
-
{
|
|
3
|
-
"extends": "../../tsconfig.json",
|
|
4
|
-
"compilerOptions": {
|
|
5
|
-
"outDir": "../../out-tsc/spec",
|
|
6
|
-
"types": [
|
|
7
|
-
"jasmine"
|
|
8
|
-
]
|
|
9
|
-
},
|
|
10
|
-
"files": [
|
|
11
|
-
"src/test.ts"
|
|
12
|
-
],
|
|
13
|
-
"include": [
|
|
14
|
-
"**/*.spec.ts",
|
|
15
|
-
"**/*.d.ts"
|
|
16
|
-
]
|
|
17
|
-
}
|