ngx-column-filter-popup 1.0.0

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.
@@ -0,0 +1,445 @@
1
+ import { Component, EventEmitter, Input, Output, ElementRef, HostListener, OnInit, ChangeDetectorRef, OnDestroy } from '@angular/core';
2
+ import { FormsModule } from '@angular/forms';
3
+ import { CommonModule } from '@angular/common';
4
+ import { Subscription } from 'rxjs';
5
+ import { FilterRule, FilterConfig, MatchType, MatchTypeOption, GlobalMatchMode, FieldType } from '../../lib/models/filter.models';
6
+ import { ColumnFilterService } from '../../lib/services/column-filter.service';
7
+
8
+ @Component({
9
+ selector: 'lib-column-filter',
10
+ standalone: true,
11
+ imports: [CommonModule, FormsModule],
12
+ templateUrl: './column-filter.component.html',
13
+ styleUrls: ['./column-filter.component.scss']
14
+ })
15
+ export class ColumnFilterComponent implements OnInit, OnDestroy {
16
+ /**
17
+ * Display name of the column (used in placeholder text)
18
+ */
19
+ @Input() columnName: string = '';
20
+
21
+ /**
22
+ * Optional: Key identifier for the column (can be used by parent component)
23
+ */
24
+ @Input() columnKey: string = '';
25
+
26
+ /**
27
+ * Optional: Initial filter configuration
28
+ */
29
+ @Input() initialFilter?: FilterConfig;
30
+
31
+ /**
32
+ * Optional: Custom placeholder text. If not provided, uses "Search by {columnName}"
33
+ */
34
+ @Input() placeholder?: string;
35
+
36
+ /**
37
+ * Optional: Customize available match types. If not provided, uses all default match types.
38
+ */
39
+ @Input() availableMatchTypes?: MatchType[];
40
+
41
+ /**
42
+ * Field type: 'text', 'currency', 'date', or 'status'
43
+ */
44
+ @Input() fieldType: FieldType = 'text';
45
+
46
+ /**
47
+ * Status options for status field type
48
+ */
49
+ @Input() statusOptions: string[] = [];
50
+
51
+ /**
52
+ * Currency symbol (default: '$')
53
+ */
54
+ @Input() currencySymbol: string = '$';
55
+
56
+ /**
57
+ * Emitted when filter is applied with the filter configuration
58
+ */
59
+ @Output() filterApplied = new EventEmitter<FilterConfig>();
60
+
61
+ /**
62
+ * Emitted when filter is cleared
63
+ */
64
+ @Output() filterCleared = new EventEmitter<void>();
65
+
66
+ /**
67
+ * Controls dropdown visibility
68
+ */
69
+ showDropdown = false;
70
+
71
+ /**
72
+ * Unique ID for this filter instance
73
+ */
74
+ private filterId: string;
75
+
76
+ /**
77
+ * Subscription to filter service
78
+ */
79
+ private filterServiceSubscription?: Subscription;
80
+
81
+ /**
82
+ * Array of filter rules
83
+ */
84
+ filterRules: FilterRule[] = [];
85
+
86
+ /**
87
+ * Global match mode: how to combine multiple rules
88
+ */
89
+ globalMatchMode: GlobalMatchMode = 'match-any-rule';
90
+
91
+ /**
92
+ * Default match types for text fields
93
+ */
94
+ private textMatchTypes: MatchTypeOption[] = [
95
+ { value: 'match-all', label: 'Match All' },
96
+ { value: 'match-any', label: 'Match Any' },
97
+ { value: 'starts-with', label: 'Starts with' },
98
+ { value: 'ends-with', label: 'Ends with' },
99
+ { value: 'contains', label: 'Contains' },
100
+ { value: 'equals', label: 'Equals' }
101
+ ];
102
+
103
+ /**
104
+ * Match types for currency/number fields
105
+ */
106
+ private currencyMatchTypes: MatchTypeOption[] = [
107
+ { value: 'equals', label: 'Equals' },
108
+ { value: 'greater-than', label: 'Greater than' },
109
+ { value: 'less-than', label: 'Less than' },
110
+ { value: 'greater-equal', label: 'Greater or equal' },
111
+ { value: 'less-equal', label: 'Less or equal' }
112
+ ];
113
+
114
+ /**
115
+ * Match types for age/number fields (same as currency)
116
+ */
117
+ private ageMatchTypes: MatchTypeOption[] = [
118
+ { value: 'equals', label: 'Equals' },
119
+ { value: 'greater-than', label: 'Greater than' },
120
+ { value: 'less-than', label: 'Less than' },
121
+ { value: 'greater-equal', label: 'Greater or equal' },
122
+ { value: 'less-equal', label: 'Less or equal' }
123
+ ];
124
+
125
+ /**
126
+ * Match types for date fields
127
+ */
128
+ private dateMatchTypes: MatchTypeOption[] = [
129
+ { value: 'is', label: 'Date is' },
130
+ { value: 'is-not', label: 'Date is not' },
131
+ { value: 'is-before', label: 'Date is before' },
132
+ { value: 'is-after', label: 'Date is after' },
133
+ { value: 'is-on', label: 'Date is on' }
134
+ ];
135
+
136
+ /**
137
+ * Match types for status fields
138
+ */
139
+ private statusMatchTypes: MatchTypeOption[] = [
140
+ { value: 'is', label: 'Is' },
141
+ { value: 'is-not', label: 'Is not' },
142
+ { value: 'equals', label: 'Equals' }
143
+ ];
144
+
145
+ /**
146
+ * Getter for match types based on field type
147
+ */
148
+ get matchTypes(): MatchTypeOption[] {
149
+ // If custom match types provided, use those
150
+ if (this.availableMatchTypes && this.availableMatchTypes.length > 0) {
151
+ const allTypes = [...this.textMatchTypes, ...this.currencyMatchTypes, ...this.dateMatchTypes, ...this.statusMatchTypes];
152
+ return allTypes.filter(type =>
153
+ this.availableMatchTypes!.includes(type.value)
154
+ );
155
+ }
156
+
157
+ // Return match types based on field type
158
+ switch (this.fieldType) {
159
+ case 'currency':
160
+ return this.currencyMatchTypes;
161
+ case 'age':
162
+ return this.ageMatchTypes;
163
+ case 'date':
164
+ return this.dateMatchTypes;
165
+ case 'status':
166
+ return this.statusMatchTypes;
167
+ case 'text':
168
+ default:
169
+ return this.textMatchTypes;
170
+ }
171
+ }
172
+
173
+ constructor(
174
+ private elementRef: ElementRef,
175
+ private cdr: ChangeDetectorRef,
176
+ private filterService: ColumnFilterService
177
+ ) {
178
+ // Generate unique ID for this filter instance
179
+ this.filterId = `filter-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
180
+ // Initialize with one default rule
181
+ this.addRule();
182
+ }
183
+
184
+ /**
185
+ * Lifecycle hook: Initialize with initial filter if provided
186
+ */
187
+ ngOnInit() {
188
+ if (this.initialFilter && this.initialFilter.rules && this.initialFilter.rules.length > 0) {
189
+ this.filterRules = [...this.initialFilter.rules];
190
+ }
191
+ if (this.initialFilter?.globalMatchMode) {
192
+ this.globalMatchMode = this.initialFilter.globalMatchMode;
193
+ }
194
+
195
+ // Subscribe to filter service to handle only one filter open at a time
196
+ this.filterServiceSubscription = this.filterService.openFilterId$.subscribe((openFilterId: string | null) => {
197
+ if (openFilterId !== null && openFilterId !== this.filterId && this.showDropdown) {
198
+ this.showDropdown = false;
199
+ this.cdr.detectChanges();
200
+ }
201
+ });
202
+ }
203
+
204
+ /**
205
+ * Cleanup subscriptions
206
+ */
207
+ ngOnDestroy(): void {
208
+ if (this.filterServiceSubscription) {
209
+ this.filterServiceSubscription.unsubscribe();
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Close dropdown when clicking outside
215
+ */
216
+ @HostListener('document:click', ['$event'])
217
+ onDocumentClick(event: MouseEvent): void {
218
+ if (!this.elementRef.nativeElement.contains(event.target)) {
219
+ if (this.showDropdown) {
220
+ this.showDropdown = false;
221
+ this.filterService.closeFilter(this.filterId);
222
+ }
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Close dropdown on ESC key press
228
+ */
229
+ @HostListener('document:keydown', ['$event'])
230
+ onEscapeKey(event: KeyboardEvent): void {
231
+ if (event.key === 'Escape' && this.showDropdown) {
232
+ this.showDropdown = false;
233
+ this.filterService.closeFilter(this.filterId);
234
+ this.cdr.detectChanges();
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Toggle dropdown visibility
240
+ */
241
+ toggleDropdown(event: Event): void {
242
+ event.stopPropagation();
243
+ const newState = !this.showDropdown;
244
+ this.showDropdown = newState;
245
+
246
+ // Notify service about filter state change
247
+ if (newState) {
248
+ this.filterService.openFilter(this.filterId);
249
+ } else {
250
+ this.filterService.closeFilter(this.filterId);
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Add a new filter rule
256
+ */
257
+ addRule(): void {
258
+ let defaultMatchType: MatchType = 'contains';
259
+ if (this.fieldType === 'currency' || this.fieldType === 'age') {
260
+ defaultMatchType = 'equals';
261
+ } else if (this.fieldType === 'date') {
262
+ defaultMatchType = 'is';
263
+ } else if (this.fieldType === 'status') {
264
+ defaultMatchType = 'is';
265
+ }
266
+
267
+ this.filterRules.push({
268
+ id: this.generateId(),
269
+ matchType: defaultMatchType,
270
+ value: ''
271
+ });
272
+ }
273
+
274
+ /**
275
+ * Remove a filter rule at the given index
276
+ */
277
+ removeRule(index: number): void {
278
+ if (this.filterRules.length > 1) {
279
+ this.filterRules.splice(index, 1);
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Apply the filter and emit the filter configuration
285
+ */
286
+ applyFilter(): void {
287
+ const validRules = this.filterRules.filter(rule => {
288
+ if (this.fieldType === 'status') {
289
+ return rule.value !== '';
290
+ }
291
+ return rule.value.trim() !== '';
292
+ });
293
+ if (validRules.length > 0) {
294
+ this.filterApplied.emit({
295
+ rules: validRules,
296
+ globalMatchMode: this.globalMatchMode,
297
+ fieldType: this.fieldType,
298
+ statusOptions: this.statusOptions.length > 0 ? this.statusOptions : undefined
299
+ });
300
+ }
301
+ this.showDropdown = false;
302
+ this.filterService.closeFilter(this.filterId);
303
+ }
304
+
305
+ /**
306
+ * Clear all filter rules and emit clear event
307
+ * This method can be called programmatically from parent components
308
+ */
309
+ clearFilter(): void {
310
+ this.filterRules = [];
311
+ this.addRule(); // Add one empty rule
312
+ this.globalMatchMode = 'match-any-rule'; // Reset to default
313
+ this.filterCleared.emit();
314
+ this.showDropdown = false;
315
+ this.filterService.closeFilter(this.filterId);
316
+ // Force change detection to update UI icon
317
+ this.cdr.detectChanges();
318
+ }
319
+
320
+ /**
321
+ * Toggle global match mode
322
+ */
323
+ toggleGlobalMatchMode(): void {
324
+ this.globalMatchMode = this.globalMatchMode === 'match-any-rule'
325
+ ? 'match-all-rules'
326
+ : 'match-any-rule';
327
+ }
328
+
329
+ /**
330
+ * Check if there are any active filters (non-empty rules)
331
+ */
332
+ hasActiveFilter(): boolean {
333
+ if (!this.filterRules || this.filterRules.length === 0) {
334
+ return false;
335
+ }
336
+ return this.filterRules.some(rule => {
337
+ if (!rule || !rule.value) {
338
+ return false;
339
+ }
340
+ // For status fields, check if value is not empty string
341
+ if (this.fieldType === 'status') {
342
+ return rule.value.trim() !== '';
343
+ }
344
+ // For other fields, check if trimmed value is not empty
345
+ return rule.value.trim() !== '';
346
+ });
347
+ }
348
+
349
+ /**
350
+ * Get placeholder text for the input field
351
+ */
352
+ getPlaceholder(): string {
353
+ if (this.placeholder) {
354
+ return this.placeholder;
355
+ }
356
+ return this.columnName ? `Search by ${this.columnName.toLowerCase()}` : 'Search';
357
+ }
358
+
359
+ /**
360
+ * Get label for a match type value
361
+ */
362
+ getMatchTypeLabel(value: MatchType): string {
363
+ const type = this.matchTypes.find(t => t.value === value);
364
+ return type ? type.label : '';
365
+ }
366
+
367
+ /**
368
+ * Check if there are multiple rules (to show global match mode toggle)
369
+ */
370
+ hasMultipleRules(): boolean {
371
+ return this.filterRules.length > 1;
372
+ }
373
+
374
+ /**
375
+ * Get label for global match mode
376
+ */
377
+ getGlobalMatchModeLabel(): string {
378
+ return this.globalMatchMode === 'match-all-rules' ? 'Match All Rules' : 'Match Any Rule';
379
+ }
380
+
381
+ /**
382
+ * Format currency value for display
383
+ */
384
+ formatCurrency(value: string): string {
385
+ if (!value || value.trim() === '') return '';
386
+ const numValue = parseFloat(value.replace(/[^0-9.-]/g, ''));
387
+ if (isNaN(numValue)) return value;
388
+ // Format with currency symbol and commas, but don't add decimals if not present
389
+ const formatted = numValue.toLocaleString('en-US', {
390
+ minimumFractionDigits: 0,
391
+ maximumFractionDigits: 2
392
+ });
393
+ return this.currencySymbol + formatted;
394
+ }
395
+
396
+ /**
397
+ * Parse currency value from formatted string
398
+ */
399
+ parseCurrency(value: string): string {
400
+ // Remove currency symbol and commas, keep numbers and decimal point
401
+ return value.replace(/[^0-9.-]/g, '');
402
+ }
403
+
404
+ /**
405
+ * Check if field type is status
406
+ */
407
+ isStatusField(): boolean {
408
+ return this.fieldType === 'status';
409
+ }
410
+
411
+ /**
412
+ * Check if field type is currency
413
+ */
414
+ isCurrencyField(): boolean {
415
+ return this.fieldType === 'currency';
416
+ }
417
+
418
+ /**
419
+ * Check if field type is age
420
+ */
421
+ isAgeField(): boolean {
422
+ return this.fieldType === 'age';
423
+ }
424
+
425
+ /**
426
+ * Check if field type is date
427
+ */
428
+ isDateField(): boolean {
429
+ return this.fieldType === 'date';
430
+ }
431
+
432
+ /**
433
+ * Check if field type is text
434
+ */
435
+ isTextField(): boolean {
436
+ return this.fieldType === 'text';
437
+ }
438
+
439
+ /**
440
+ * Generate a unique ID for a filter rule
441
+ */
442
+ private generateId(): string {
443
+ return `rule-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
444
+ }
445
+ }
@@ -0,0 +1,31 @@
1
+ import { NgModule } from '@angular/core';
2
+ import { CommonModule } from '@angular/common';
3
+ import { FormsModule } from '@angular/forms';
4
+ import { ColumnFilterComponent } from './column-filter.component';
5
+
6
+ /**
7
+ * Module for Column Filter Component
8
+ *
9
+ * This module provides the ColumnFilterComponent for use in Angular applications
10
+ * that prefer module-based imports over standalone components.
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * import { ColumnFilterModule } from './components/column-filter/column-filter.module';
15
+ *
16
+ * @NgModule({
17
+ * imports: [ColumnFilterModule],
18
+ * // ...
19
+ * })
20
+ * export class YourModule {}
21
+ * ```
22
+ */
23
+ @NgModule({
24
+ imports: [
25
+ CommonModule,
26
+ FormsModule,
27
+ ColumnFilterComponent // Standalone component can be imported in module
28
+ ],
29
+ exports: [ColumnFilterComponent]
30
+ })
31
+ export class ColumnFilterModule { }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Field type for different column types
3
+ */
4
+ export type FieldType = 'text' | 'currency' | 'date' | 'status' | 'age';
5
+
6
+ /**
7
+ * Match type options for filtering
8
+ */
9
+ export type MatchType = 'match-all' | 'match-any' | 'starts-with' | 'ends-with' | 'contains' | 'equals' | 'greater-than' | 'less-than' | 'greater-equal' | 'less-equal' | 'is-before' | 'is-after' | 'is-on' | 'is-not' | 'is';
10
+
11
+ /**
12
+ * Individual filter rule
13
+ */
14
+ export interface FilterRule {
15
+ id: string;
16
+ matchType: MatchType;
17
+ value: string;
18
+ }
19
+
20
+ /**
21
+ * Global match mode for combining multiple filter rules
22
+ */
23
+ export type GlobalMatchMode = 'match-all-rules' | 'match-any-rule';
24
+
25
+ /**
26
+ * Filter configuration containing multiple rules
27
+ */
28
+ export interface FilterConfig {
29
+ rules: FilterRule[];
30
+ /**
31
+ * Global match mode: 'match-all-rules' means ALL rules must match (AND logic),
32
+ * 'match-any-rule' means ANY rule can match (OR logic). Default is 'match-any-rule'.
33
+ */
34
+ globalMatchMode?: GlobalMatchMode;
35
+ /**
36
+ * Field type for this column filter
37
+ */
38
+ fieldType?: FieldType;
39
+ /**
40
+ * Status options (for status field type)
41
+ */
42
+ statusOptions?: string[];
43
+ }
44
+
45
+ /**
46
+ * Match type configuration for UI
47
+ */
48
+ export interface MatchTypeOption {
49
+ value: MatchType;
50
+ label: string;
51
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Public API for Column Filter Library
3
+ *
4
+ * This file exports all public APIs that will be available when
5
+ * this library is packaged and published to npm.
6
+ */
7
+
8
+ // Models
9
+ export * from './models/filter.models';
10
+
11
+ // Utilities
12
+ export * from './utils/column-filter.utils';
13
+
14
+ // Services
15
+ export * from './services/column-filter.service';
16
+
17
+ // Components
18
+ export * from '../components/column-filter/column-filter.component';
19
+
20
+ // Module (optional, for module-based imports)
21
+ export * from '../components/column-filter/column-filter.module';
@@ -0,0 +1,35 @@
1
+ import { Injectable } from '@angular/core';
2
+ import { Subject } from 'rxjs';
3
+
4
+ /**
5
+ * Service to manage column filter state across multiple filter components
6
+ * Ensures only one filter dropdown is open at a time
7
+ */
8
+ @Injectable({
9
+ providedIn: 'root'
10
+ })
11
+ export class ColumnFilterService {
12
+ private openFilterId = new Subject<string | null>();
13
+ openFilterId$ = this.openFilterId.asObservable();
14
+
15
+ /**
16
+ * Notify that a filter is now open
17
+ */
18
+ openFilter(filterId: string): void {
19
+ this.openFilterId.next(filterId);
20
+ }
21
+
22
+ /**
23
+ * Notify that a filter is now closed
24
+ */
25
+ closeFilter(filterId: string): void {
26
+ this.openFilterId.next(null);
27
+ }
28
+
29
+ /**
30
+ * Close all open filters
31
+ */
32
+ closeAllFilters(): void {
33
+ this.openFilterId.next(null);
34
+ }
35
+ }