import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core';
import { ForAngularModule } from '../../for-angular.module';
import { NgxBaseComponent } from '../../engine/NgxBaseComponent';
import { IonChip, IonIcon, IonItem, IonLabel, IonSelect} from '@ionic/angular/standalone';
import { Dynamic, IFilterQuery, IFilterQueryItem } from '../../engine';
import { getWindowWidth } from '../../helpers/utils';
import { debounceTime, fromEvent, Subscription } from 'rxjs';
import { OrderDirection, Repository } from '@decaf-ts/core';
import { Model } from '@decaf-ts/decorator-validation';
import { SearchbarComponent } from '../searchbar/searchbar.component';
import { addIcons } from 'ionicons';
import { chevronDownOutline, chevronUpOutline } from 'ionicons/icons';
/**
* @description Advanced filter component for creating dynamic search filters with step-by-step construction.
* @summary This component provides a comprehensive filtering interface that allows users to build
* complex search criteria using a three-step approach: select index → select condition → enter value.
* It supports filtering by multiple field indexes, comparison conditions, and values, displaying
* selected filters as removable chips. The component is responsive and includes auto-suggestions
* with keyboard navigation support.
*
* @example
* ```html
* <ngx-decaf-filter
* [indexes]="['name', 'email', 'department', 'status']"
* [conditions]="['Equal', 'Contains', 'Greater Than', 'Less Than']"
* [sort]="['createdAt', 'updatedAt']"
* [disableSort]="false"
* (filterEvent)="onFiltersChanged($event)">
* </ngx-decaf-filter>
* ```
*
* @mermaid
* sequenceDiagram
* participant U as User
* participant F as FilterComponent
* participant P as Parent Component
*
* U->>F: Focus input field
* F->>F: handleFocus() - Show available indexes
* U->>F: Select index (e.g., "name")
* F->>F: addFilter() - Step 1 completed
* F->>F: Show available conditions
* U->>F: Select condition (e.g., "Contains")
* F->>F: addFilter() - Step 2 completed
* F->>F: Show value input prompt
* U->>F: Enter value and press Enter
* F->>F: addFilter() - Step 3 completed
* F->>F: Create complete filter object
* F->>P: Emit filterEvent with new filter array
* F->>F: Reset to step 1 for next filter
*
* @memberOf ForAngularModule
*/
@Dynamic()
@Component({
selector: 'ngx-decaf-filter',
templateUrl: './filter.component.html',
styleUrls: ['./filter.component.scss'],
imports: [
ForAngularModule,
IonLabel,
IonItem,
IonChip,
IonIcon,
IonSelect,
IonIcon,
SearchbarComponent
],
standalone: true,
})
export class FilterComponent extends NgxBaseComponent implements OnInit, OnDestroy {
/**
* @description Reference to the dropdown options container element.
* @summary ViewChild reference used to access and manipulate the dropdown options element
* for highlighting filtered items and managing visual feedback during option selection.
* This element contains the filterable suggestions that users can interact with.
*
* @type {ElementRef}
* @memberOf FilterComponent
*/
@ViewChild('optionsFilterElement', { read: ElementRef, static: false })
optionsFilterElement!: ElementRef;
/**
* @description Available field indexes for filtering operations.
* @summary Defines the list of field names that users can filter by. These represent
* the data properties available for filtering operations. Each index corresponds to
* a field in the data model that supports comparison operations.
*
* @type {string[]}
* @default []
* @memberOf FilterComponent
*/
@Input()
indexes: string[] = [];
/**
* @description Available comparison conditions for filters.
* @summary Defines the list of comparison operators that can be used when creating filters.
* These conditions determine how the filter value is compared against the field value.
* Common conditions include equality, containment, and numerical comparison operations.
*
* @type {string[]}
* @default []
* @memberOf FilterComponent
*/
@Input()
conditions: string[] = ['Equal', 'Contains', 'Not Contains', 'Greater Than', 'Less Than', 'Not Equal'];
/**
* @description Available sorting options for the filtered data.
* @summary Defines the list of field names that can be used for sorting the filtered results.
* When disableSort is false, this array is automatically merged with the indexes array
* to provide comprehensive sorting capabilities.
*
* @type {string[]}
* @default []
* @memberOf FilterComponent
*/
@Input()
sortBy: string[] = [];
/**
* @description Controls whether sorting functionality is disabled.
* @summary When set to true, prevents the automatic merging of sort and indexes arrays,
* effectively disabling sorting capabilities. This is useful when you want to provide
* filtering without sorting options.
*
* @type {boolean}
* @default false
* @memberOf FilterComponent
*/
@Input()
disableSort: boolean = false;
/**
* @description Current window width for responsive behavior.
* @summary Stores the current browser window width in pixels. This value is updated
* on window resize events to enable responsive filtering behavior and layout adjustments
* based on available screen space.
*
* @type {number}
* @memberOf FilterComponent
*/
windowWidth!: number;
/**
* @description Available options for the current filter step.
* @summary Contains the list of options available for selection in the current step.
* This array changes dynamically based on the current step: indexes → conditions → empty for value input.
*
* @type {string[]}
* @default []
* @memberOf FilterComponent
*/
options: string[] = [];
/**
* @description Filtered options based on user input.
* @summary Contains the subset of options that match the current user input for real-time
* filtering. This array is updated as the user types to show only relevant suggestions
* in the dropdown menu.
*
* @type {string[]}
* @default []
* @memberOf FilterComponent
*/
filteredOptions: string[] = [];
/**
* @description Complete filter objects created by the user.
* @summary Array of complete filter objects, each containing index, condition, and value properties.
* These represent the active filters that can be applied to data operations.
*
* @type {KeyValue[]}
* @default []
* @memberOf FilterComponent
*/
filterValue: IFilterQueryItem[] = [];
/**
* @description Current filter being constructed.
* @summary Temporary object that accumulates filter properties (index, condition, value)
* during the three-step filter creation process. Gets added to filterValue when complete.
*
* @type {KeyValue}
* @default {}
* @memberOf FilterComponent
*/
lastFilter: IFilterQueryItem = {};
/**
* @description Current step in the filter creation process.
* @summary Tracks the current step of filter creation: 1 = index selection, 2 = condition selection,
* 3 = value input. Automatically resets to 1 after completing a filter.
*
* @type {number}
* @default 1
* @memberOf FilterComponent
*/
step: number = 1;
/**
* @description Controls dropdown visibility state.
* @summary Boolean flag that determines whether the options dropdown is currently visible.
* Used to manage the dropdown's open/close state and coordinate with focus/blur events.
*
* @type {boolean}
* @default false
* @memberOf FilterComponent
*/
dropdownOpen: boolean = false;
/**
* @description Current input field value.
* @summary Stores the current text input value that the user is typing. This value is
* bound to the input field and is cleared after each successful filter step completion.
*
* @type {string}
* @default ''
* @memberOf FilterComponent
*/
value: string = '';
/**
* @description Current sorting field value.
* @summary Stores the field name currently selected for sorting operations.
* This value determines which field is used to order the filtered results.
* Defaults to 'id' and can be changed through the sort dropdown selection.
*
* @type {string}
* @default 'id'
* @memberOf FilterComponent
*/
sortValue: string = 'id';
/**
* @description Current sorting direction.
* @summary Defines the direction of the sort operation - ascending or descending.
* This value works in conjunction with sortValue to determine the complete
* sorting configuration for filtered results.
*
* @type {OrderDirection}
* @default OrderDirection.DSC
* @memberOf FilterComponent
*/
sortDirection: OrderDirection = OrderDirection.DSC;
/**
* @description Subscription for window resize events.
* @summary RxJS subscription that listens for window resize events with debouncing
* to update the windowWidth property. This enables responsive behavior and prevents
* excessive updates during resize operations.
*
* @type {Subscription}
* @memberOf FilterComponent
*/
windowResizeSubscription!: Subscription;
/**
* @description Event emitter for filter changes.
* @summary Emits filter events when the user creates, modifies, or clears filters.
* The emitted value contains an array of complete filter objects or undefined when
* filters are cleared. Parent components listen to this event to update their data display.
*
* @type {EventEmitter<KeyValue[] | undefined>}
* @memberOf FilterComponent
*/
@Output()
filterEvent: EventEmitter<IFilterQuery | undefined> = new EventEmitter<IFilterQuery | undefined>();
/**
* @description Event emitter for search events.
* @summary Emits search events when the user interacts with the searchbar.
* @type {EventEmitter<string>}
* @memberOf FilterComponent
*/
@Output()
searchEvent: EventEmitter<string> = new EventEmitter<string>();
/**
* @description Constructor for FilterComponent.
* @summary Initializes a new instance of the FilterComponent.
* Calls the parent constructor with the component name to establish base locale string generation
* and internationalization support.
*
* @memberOf FilterComponent
*/
constructor() {
super("FilterComponent");
addIcons({chevronDownOutline, chevronUpOutline});
}
/**
* @description Initializes the component after Angular first displays the data-bound properties.
* @summary Sets up the component by initializing window width tracking, setting up resize event
* subscriptions with debouncing, configuring sorting options, and calling the base initialization.
* This method prepares the component for user interaction and responsive behavior.
*
* @mermaid
* sequenceDiagram
* participant A as Angular Lifecycle
* participant F as FilterComponent
* participant W as Window
* participant R as RxJS
*
* A->>F: ngOnInit()
* F->>W: getWindowWidth()
* W-->>F: Return current width
* F->>R: Setup resize subscription with debounce
* R-->>F: Subscription created
* alt disableSort is false
* F->>F: Merge sort and indexes arrays
* end
* F->>F: Call initialize()
*
* @returns {void}
* @memberOf FilterComponent
*/
ngOnInit(): void {
this.windowWidth = getWindowWidth() as number;
this.windowResizeSubscription = fromEvent(window, 'resize')
.pipe(debounceTime(300))
.subscribe(() => {
this.windowWidth = getWindowWidth() as number;
});
this.getIndexes();
this.initialize();
}
/**
* @description Retrieves and configures available indexes for filtering and sorting.
* @summary Extracts field indexes from the model if available and merges them with
* sorting options when sorting is enabled. This method sets up the available field
* options for both filtering and sorting operations based on the model structure.
*
* @returns {void}
* @memberOf FilterComponent
*/
getIndexes(): void {
if(this.model)
this.indexes = Object.keys(Repository.indexes(this.model as Model) || {});
if(!this.disableSort)
this.sortBy = [... this.sortBy, ...this.indexes];
}
/**
* @description Cleanup method called when the component is destroyed.
* @summary Unsubscribes from window resize events to prevent memory leaks.
* This is essential for proper cleanup of RxJS subscriptions when the component
* is removed from the DOM.
*
* @returns {void}
* @memberOf FilterComponent
*/
ngOnDestroy(): void {
this.windowResizeSubscription.unsubscribe();
this.clear();
}
/**
* @description Handles input events from the text field.
* @summary Processes user input and filters the available options based on the typed value.
* This method provides real-time filtering of suggestions as the user types in the input field.
*
* @param {InputEvent} event - The input event containing the new value
* @returns {void}
* @memberOf FilterComponent
*/
handleInput(event: InputEvent): void {
const {value} = event.target as HTMLInputElement;
this.filteredOptions = this.filterOptions(value);
}
/**
* @description Handles focus events on the input field.
* @summary Sets up the available options when the input field receives focus and opens the dropdown.
* If no options are provided, automatically determines the appropriate options based on current step.
* This method initializes the dropdown with contextually relevant suggestions.
*
* @param {string[]} options - Optional array of options to display
* @returns {void}
* @memberOf FilterComponent
*/
handleFocus(options: string[] = []): void {
if(!options.length)
options = this.getOptions();
this.filteredOptions = this.options = options;
this.dropdownOpen = true;
}
/**
* @description Handles blur events on the input field with delayed closing.
* @summary Manages the dropdown closing behavior with a delay to allow for option selection.
* Uses a two-phase approach to prevent premature closing when users click on dropdown options.
*
* @param {boolean} close - Internal flag to control the closing phase
* @returns {void}
* @memberOf FilterComponent
*/
handleBlur(close: boolean = false): void {
if(!close) {
this.dropdownOpen = false;
setTimeout(() => {
this.handleBlur(true);
}, 100);
} else {
if(!this.dropdownOpen && this.options.length) {
setTimeout(() => {
this.options = [];
this.dropdownOpen = false;
}, 50);
}
}
}
/**
* @description Determines the appropriate options based on the current filter step.
* @summary Returns the contextually relevant options for the current step in the filter creation process.
* Step 1 shows indexes, Step 2 shows conditions, Step 3 shows no options (value input).
*
* @returns {string[]} Array of options appropriate for the current step
* @memberOf FilterComponent
*/
getOptions(): string[] {
switch (this.step) {
case 1:
this.options = this.indexes;
break;
case 2:
this.options = this.conditions;
break;
case 3:
this.options = [];
break;
}
return this.options
}
/**
* @description Adds a filter step or completes filter creation through a three-step process.
* @summary Core method for building filters step by step: Step 1 (Index) → Step 2 (Condition) → Step 3 (Value).
* When all steps are complete, creates a complete filter object and adds it to the filter collection.
* Handles both keyboard events (Enter to submit) and programmatic calls.
*
* @param {string} value - The value to add for the current step
* @param {CustomEvent} event - Optional event (KeyboardEvent triggers submission when value is empty)
* @returns {void}
*
* @mermaid
* sequenceDiagram
* participant U as User
* participant F as FilterComponent
*
* U->>F: addFilter(value, event)
* F->>F: Trim and validate value
* alt KeyboardEvent && empty value
* F->>F: submit() - Send current filters
* else Valid value or step 3
* alt Step 1 (Index)
* F->>F: lastFilter.index = value
* F->>F: options = conditions
* else Step 2 (Condition)
* F->>F: lastFilter.condition = value
* F->>F: options = []
* else Step 3 (Value)
* F->>F: lastFilter.value = value
* F->>F: Add complete filter to filterValue
* F->>F: Reset step to 1
* end
* F->>F: Increment step
* F->>F: Clear input & focus
* F->>F: Show next options
* end
*
* @memberOf FilterComponent
*/
addFilter(value: string, event?: CustomEvent): void {
value = value.trim();
if(event instanceof KeyboardEvent && !value) {
this.submit();
} else {
if((value && (!(event instanceof KeyboardEvent)) || this.step === 3)) {
const filter = this.lastFilter;
switch (this.step) {
case 1:
filter['index'] = value;
this.options = this.conditions;
break;
case 2:
filter['condition'] = value;
this.options = [];
break;
case 3:
filter['value'] = value;
this.options = this.indexes;
break;
}
if(!this.filterValue.length) {
this.filterValue.push(filter);
} else {
if(this.step === 1)
this.filterValue.push(filter);
}
if(this.step === 3) {
this.step = 0;
this.filterValue[this.filterValue.length - 1] = filter;
this.lastFilter = {};
}
this.step++;
this.value = '';
if(this.options.length)
this.handleFocus(this.options);
this.component.nativeElement.focus();
}
}
}
/**
* @description Selects an option from the dropdown suggestions.
* @summary Handles option selection when a user clicks on a suggestion in the dropdown.
* This method acts as a bridge between dropdown clicks and the main addFilter logic.
*
* @param {CustomEvent} event - The click event from the dropdown option
* @param {string} value - The selected option value
* @returns {void}
* @memberOf FilterComponent
*/
selectOption(value: string): void {
this.addFilter(value);
}
/**
* @description Determines if a filter option can be individually removed.
* @summary Checks whether a filter component should display a close icon for removal.
* Only value options can be removed individually; index and condition options are part
* of the complete filter structure and cannot be removed separately.
*
* @param {string} option - The filter option text to check
* @returns {boolean} True if the option can be cleared individually, false otherwise
* @memberOf FilterComponent
*/
allowClear(option: string): boolean {
return this.indexes.indexOf(option) === -1 && this.conditions.indexOf(option) === -1;
}
/**
* @description Removes a complete filter from the collection based on filter value.
* @summary Removes a complete filter by matching the provided value against filter values
* in the collection. Uses string normalization to handle accents and case differences.
* After removal, resets the interface to show available indexes for new filter creation.
*
* @param {string} filter - The filter value to remove (matches against filter.value property)
* @returns {void}
*
* @mermaid
* sequenceDiagram
* participant U as User
* participant F as FilterComponent
*
* U->>F: removeFilter(filterValue)
* F->>F: cleanString(filterValue)
* F->>F: Filter out matching filter objects
* F->>F: Clear input value
* F->>F: handleFocus(indexes) - Reset to index selection
* Note over F: Filter removed and UI reset
*
* @memberOf FilterComponent
*/
removeFilter(filter: string): void {
function cleanString(filter: string): string {
return filter
.toLowerCase() // convert all characters to lowercase
.normalize("NFD") // separate accent marks from characters
.replace(/[\u0300-\u036f]/g, "") // remove accent marks
.replace(/\s+/g, ""); // remove all whitespace
}
this.value = "";
this.filterValue = this.filterValue.filter((item) => item?.['value'] && cleanString(item?.['value']) !== cleanString(filter));
if(this.filterValue.length === 0) {
this.step = 1;
this.lastFilter = {};
}
this.handleFocus(this.indexes);
}
/**
* @description Resets the component to its initial state.
* @summary Clears all filter data, options, and resets the step counter to 1.
* This method provides a clean slate for new filter creation without emitting events.
*
* @returns {void}
* @memberOf FilterComponent
*/
reset(): void {
this.options = this.filteredOptions = this.filterValue = [];
this.step = 1;
this.lastFilter = {};
this.value = '';
setTimeout(() => {
this.submit();
}, 100);
}
/**
* @description Clears all filters and notifies parent components.
* @summary Resets the component state and emits undefined to notify parent components
* that all filters have been cleared. This triggers any connected data refresh logic.
*
* @param {string} value - Optional parameter (currently unused)
* @returns {void}
* @memberOf FilterComponent
*/
clear(value?: string): void {
if(!value)
this.reset();
}
/**
* @description Submits the current filter collection to parent components.
* @summary Emits the current filter array to parent components when filters are ready
* to be applied. Only emits if there are active filters. Clears options after submission.
*
* @returns {void}
* @memberOf FilterComponent
*/
submit(): void {
this.filterEvent.emit({
query: this.filterValue.length > 0 ? this.filterValue : undefined,
sort: {
value: this.sortValue,
direction: this.sortDirection
}
} as IFilterQuery);
if(this.filterValue.length === 0)
this.options = [];
}
/**
* @description Toggles the sort direction between ascending and descending.
* @summary Handles sort direction changes by toggling between ASC and DSC values.
* When the direction changes, automatically triggers a submit to apply the new
* sorting configuration to the filtered results.
*
* @returns {void}
* @memberOf FilterComponent
*/
handleSortDirectionChange(): void {
const direction = this.sortDirection === OrderDirection.ASC ? OrderDirection.DSC : OrderDirection.ASC;
if(direction !== this.sortDirection) {
this.sortDirection = direction;
this.submit();
}
}
/**
* @description Handles sort field selection changes from the dropdown.
* @summary Processes sort field changes when users select a different field
* from the sort dropdown. Updates the sortValue property and triggers
* a submit to apply the new sorting configuration if the value has changed.
*
* @param {CustomEvent} event - The select change event containing the new sort field value
* @returns {void}
* @memberOf FilterComponent
*/
handleSortChange(event: CustomEvent): void {
const target = event.target as HTMLIonSelectElement;
const value = target.value;
if(value !== this.sortValue) {
this.sortValue = value as string;
this.submit();
}
}
/**
* @description Filters available options based on user input with visual highlighting.
* @summary Performs real-time filtering of available options based on user input.
* Also handles visual highlighting of matching options in the dropdown. Returns all
* options if input is less than 2 characters for performance optimization.
*
* @param {string | null | undefined} value - The search value to filter by
* @returns {string[]} Array of filtered options that match the input
*
* @mermaid
* sequenceDiagram
* participant U as User
* participant F as FilterComponent
* participant D as DOM
*
* U->>F: filterOptions(inputValue)
* alt inputValue < 2 characters
* F->>D: Remove existing highlights
* F-->>U: Return all options
* else inputValue >= 2 characters
* F->>D: Query all option elements
* F->>D: Add highlight to first matching option
* F->>F: Filter options by substring match
* F-->>U: Return filtered options
* end
*
* @memberOf FilterComponent
*/
filterOptions(value: string | null | undefined): string[] {
const optionsElement = this.optionsFilterElement.nativeElement;
if(!value?.length || !value || value.length < 2) {
const filteredOption = optionsElement.querySelector('.dcf-filtering-item');
if(filteredOption)
filteredOption.classList.remove('dcf-filtering-item');
return this.options;
}
const options = optionsElement.querySelectorAll('.dcf-item');
for (const option of options) {
const isActive = option.textContent?.toLowerCase().includes(value.toLowerCase());
if(isActive) {
option.classList.add('dcf-filtering-item');
break;
}
}
return this.options.filter((option: string) => option.toLowerCase().includes(value.toLowerCase() as string));
}
/**
* @description Handles search events from the integrated searchbar component.
* @summary Processes search input from the searchbar and emits search events
* to parent components. This method acts as a bridge between the internal
* searchbar component and external search event listeners.
*
* @param {string | undefined} value - The search value entered by the user
* @returns {void}
* @memberOf FilterComponent
*/
handleSearch(value: string | undefined): void {
this.searchEvent.emit(value);
}
}
Source