src/components/sq-select-multi/sq-select-multi.component.ts
Multi-select dropdown component with advanced features including:
<sq-select-multi
[options]="cities"
[(value)]="selectedCities"
[maxSelects]="3"
[width]="'300px'"
[minWidth]="'250px'"
[dropdownWidth]="'350px'"
></sq-select-multi>
OnChanges
| changeDetection | ChangeDetectionStrategy.OnPush |
| selector | sq-select-multi |
| standalone | true |
| imports |
NgClass
NgStyle
NgTemplateOutlet
AsyncPipe
FormsModule
ScrollingModule
SqLoaderComponent
SqTooltipComponent
SqSelectorComponent
UniversalSafePipe
TranslateInternalPipe
SearchPipe
SearchValidValuesPipe
SqClickOutsideDirective
|
| styleUrls | ./sq-select-multi.component.scss |
| templateUrl | ./sq-select-multi.component.html |
constructor(element: ElementRef, translate: TranslateService, changeDetector: ChangeDetectorRef)
|
||||||||||||||||
|
Constructor for the SqSelectMultiComponent. Initializes the component with default values and dependencies.
Parameters :
|
| backgroundColor | |
Type : string
|
|
Default value : ''
|
|
|
Background color for the select input. |
|
| borderColor | |
Type : string
|
|
Default value : ''
|
|
|
Border color for the select input. |
|
| customClass | |
Type : string
|
|
Default value : ''
|
|
|
Custom CSS class to apply to the component container. |
|
| disabled | |
Type : boolean
|
|
Default value : false
|
|
|
Disables the select input when true. |
|
| dropdownWidth | |
Type : string
|
|
Default value : '100%'
|
|
|
Width of the dropdown panel. Can be different from the trigger width. Default: '100%' |
|
| errorSpan | |
Type : boolean
|
|
Default value : true
|
|
|
Shows error message container when true. |
|
| externalError | |
Type : string
|
|
Default value : ''
|
|
|
External error message to display. |
|
| externalIcon | |
Type : string
|
|
Default value : ''
|
|
|
External icon to display. |
|
| hideSearch | |
Type : boolean
|
|
Default value : false
|
|
|
Hides the search input when true. |
|
| id | |
Type : string
|
|
|
ID attribute for the select element. |
|
| label | |
Type : string
|
|
Default value : ''
|
|
|
Label text displayed above the select input. |
|
| labelColor | |
Type : string
|
|
Default value : ''
|
|
|
Color for the label text. |
|
| loading | |
Type : boolean
|
|
Default value : false
|
|
|
Shows loading spinner when true. |
|
| maxSelects | |
Type : number
|
|
|
Maximum number of items that can be selected. |
|
| minCharactersToSearch | |
Type : number
|
|
Default value : 0
|
|
|
Minimum characters required to trigger search. |
|
| minSelects | |
Type : number
|
|
|
Minimum number of items required to be selected. |
|
| minWidth | |
Type : string
|
|
Default value : '200px'
|
|
|
Minimum width of the component container. Default: '200px' |
|
| name | |
Type : string
|
|
Default value : `random-name-${(1 + Date.now() + Math.random()).toString().replace('.', '')}`
|
|
|
Name attribute for the select element. Defaults to a random unique name if not provided. |
|
| options | |
Type : Array<OptionMulti>
|
|
Default value : []
|
|
|
Array of available options for selection. |
|
| placeholder | |
Type : string
|
|
Default value : ''
|
|
|
Placeholder text shown when no options are selected. |
|
| placeholderSearch | |
Type : string
|
|
Default value : ''
|
|
|
Placeholder text for the search input. |
|
| readonly | |
Type : boolean
|
|
Default value : false
|
|
|
Makes the select input read-only when true. |
|
| required | |
Type : boolean
|
|
Default value : false
|
|
|
Marks the field as required when true. |
|
| showInside | |
Type : boolean
|
|
Default value : true
|
|
|
Shows selected items inside the input when true. |
|
| timeToChange | |
Type : number
|
|
Default value : 800
|
|
|
Debounce time (in ms) for search input. |
|
| tooltipColor | |
Type : string
|
|
Default value : 'inherit'
|
|
|
Color scheme for the tooltip. |
|
| tooltipIcon | |
Type : string
|
|
Default value : ''
|
|
|
Icon to display with the tooltip. |
|
| tooltipMessage | |
Type : string
|
|
Default value : ''
|
|
|
Tooltip message content. |
|
| tooltipPlacement | |
Type : "center top" | "center bottom" | "left center" | "right center"
|
|
Default value : 'right center'
|
|
|
Position of the tooltip relative to the label. |
|
| useFormErrors | |
Type : boolean
|
|
Default value : true
|
|
|
Enables form error messages when true. |
|
| value | |
Type : OptionMulti[]
|
|
Default value : []
|
|
|
Currently selected options (two-way bindable). |
|
| width | |
Type : string
|
|
Default value : '100%'
|
|
|
Width of the component container. Can be any valid CSS width value (px, %, etc). Default: '100%' |
|
| closeChange | |
Type : EventEmitter<boolean>
|
|
|
Emitted when the dropdown closes. Contains boolean indicating if selection changed. |
|
| searchChange | |
Type : EventEmitter<string>
|
|
|
Emitted when the search text changes (with debounce). |
|
| valid | |
Type : EventEmitter<boolean>
|
|
|
Emitted when validation status changes. True when valid, false when invalid. |
|
| valueChange | |
Type : EventEmitter<Array<OptionMulti>>
|
|
|
Emitted when the selected values change. |
|
| closeDropdown |
closeDropdown()
|
|
Closes the dropdown and resets search state.
Returns :
void
|
| Async doDropDownAction |
doDropDownAction()
|
|
Toggles the dropdown open/closed state. Handles animations and rendering of options list.
Returns :
any
|
| emit | ||||||||||||
emit(object: OptionMulti, checked: boolean)
|
||||||||||||
|
Adds or removes an item from the selection.
Parameters :
Returns :
void
|
| handleCollapse | ||||||||
handleCollapse(item: OptionMulti)
|
||||||||
|
Handles expanding/collapsing of hierarchical options.
Parameters :
Returns :
void
|
| Async modelChange | ||||||||
modelChange(event: any)
|
||||||||
|
Handles search input changes with debounce.
Parameters :
Returns :
any
|
| Async ngOnChanges | ||||||||
ngOnChanges(changes: SimpleChanges)
|
||||||||
|
Angular lifecycle hook called when input properties change.
Parameters :
Returns :
any
|
| Async setError | |||||||||||||||
setError(key: string, interpolateParams: Object)
|
|||||||||||||||
|
Sets an error message using translation.
Parameters :
Returns :
any
|
| validate |
validate()
|
|
Validates the current selection state. Updates error messages and validity status.
Returns :
void
|
| verifyNewOptions |
verifyNewOptions()
|
|
Updates options list and calculates virtual scroll viewport height. Adjusts based on number of options for optimal performance.
Returns :
void
|
| _options |
Type : Array<OptionMulti>
|
Default value : []
|
|
Internal list of options after filters are applied |
| cdkItemSize |
Type : string | null
|
Default value : '32'
|
|
Size of the items in the virtual scroll (in pixels) |
| cdkVirtualScrollViewportHeight |
Type : string
|
Default value : '305px'
|
|
Dynamic height of the virtual scroll viewport Automatically calculated based on the number of options |
| Public element |
Type : ElementRef
|
|
- ElementRef for accessing native DOM element
|
| error |
Type : boolean | string
|
Default value : ''
|
|
Current error message or error boolean state |
| ismaxSelects |
Default value : false
|
|
Flag indicating whether the maximum number of selections has been reached |
| labelTemplate |
Type : TemplateRef<HTMLElement> | null
|
Default value : null
|
Decorators :
@ContentChild('labelTemplate')
|
|
Custom template for the label content. |
| nativeElement |
Type : ElementRef
|
|
Reference to the native element of the component |
| open |
Default value : false
|
|
State that controls whether the dropdown is open or closed |
| renderOptionsList |
Default value : false
|
|
Flag indicating whether the rendering of the options list is active |
| searchText |
Type : string
|
Default value : ''
|
|
Current text used for search/filter |
| selectEmptyTemplate |
Type : TemplateRef<HTMLElement> | null
|
Default value : null
|
Decorators :
@ContentChild('selectEmptyTemplate')
|
|
Custom template to display when no options are available. |
| timeouted |
Default value : false
|
|
Temporary flag for timeout control |
| timeoutInput |
Type : ReturnType<>
|
|
Reference to the search timeout for debounce |
| trackByOptValue | ||||||
Type : TrackByFunction<any>
|
||||||
Default value : useMemo((index, opt) => opt.value)
|
||||||
|
TrackBy function for ngFor optimizations. |
||||||
|
Parameters :
|
| valueChanged |
Default value : false
|
|
Flag indicating whether the value has changed since the last opening |
| verifyIfHasChildrenInValue | ||||||
Default value : useMemo((item: OptionMulti, value?: Array<OptionMulti>) => {
if (item.children?.length) {
const hasAllChildren = item.children.every(child => this.findItemInValue(child, value));
if (hasAllChildren && !this.findItemInValue(item, value) && !this.timeouted) {
this.timeouted = true;
setTimeout(() => {
this.emit(item, true);
this.timeouted = false;
}, 0);
}
return !!item.children.find(child => this.findItemInValue(child, value));
}
return false;
})
|
||||||
|
Checks if an item has any selected children. |
||||||
|
Parameters :
|
| verifyIfOptionsHasChildren | ||||
Default value : useMemo((options: OptionMulti[]) => {
return options.some(item => item.children?.length);
})
|
||||
|
Checks if any options have children (hierarchy). |
||||
|
Parameters :
|
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ContentChild,
ElementRef,
EventEmitter,
Input,
OnChanges,
Optional,
Output,
SimpleChanges,
TemplateRef,
TrackByFunction,
} from '@angular/core';
import { NgClass, NgStyle, NgTemplateOutlet, AsyncPipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { TranslateService } from '@ngx-translate/core';
import { useMemo } from '../../helpers/memo.helper';
import { OptionMulti } from '../../interfaces/option.interface';
import { SqLoaderComponent } from '../sq-loader/sq-loader.component';
import { SqTooltipComponent } from '../sq-tooltip/sq-tooltip.component';
import { SqSelectorComponent } from '../sq-selector/sq-selector.component';
import { UniversalSafePipe } from '../../pipes/universal-safe/universal-safe.pipe';
import { TranslateInternalPipe } from '../../pipes/translate-internal/translate-internal.pipe';
import { SearchPipe } from '../../pipes/search/search.pipe';
import { SqClickOutsideDirective } from '../../directives/sq-click-outside/sq-click-outside.directive';
import { SearchValidValuesPipe } from '../../pipes/search-valid-values/search-valid-values.pipe';
/**
* Multi-select dropdown component with advanced features including:
* - Single or multiple selection
* - Search/filter functionality
* - Hierarchical options (nested children)
* - Custom templates
* - Virtual scrolling for large lists
*
* @example
* <sq-select-multi
* [options]="cities"
* [(value)]="selectedCities"
* [maxSelects]="3"
* [width]="'300px'"
* [minWidth]="'250px'"
* [dropdownWidth]="'350px'"
* ></sq-select-multi>
*/
@Component({
selector: 'sq-select-multi',
templateUrl: './sq-select-multi.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: ['./sq-select-multi.component.scss'],
standalone: true,
imports: [
NgClass,
NgStyle,
NgTemplateOutlet,
AsyncPipe,
FormsModule,
ScrollingModule,
SqLoaderComponent,
SqTooltipComponent,
SqSelectorComponent,
UniversalSafePipe,
TranslateInternalPipe,
SearchPipe,
SearchValidValuesPipe,
SqClickOutsideDirective,
],
providers: [],
})
export class SqSelectMultiComponent implements OnChanges {
// #region Input Properties
/**
* Name attribute for the select element.
* Defaults to a random unique name if not provided.
*/
@Input() name = `random-name-${(1 + Date.now() + Math.random()).toString().replace('.', '')}`;
/**
* Currently selected options (two-way bindable).
*/
@Input() value: OptionMulti[] = [];
/**
* ID attribute for the select element.
*/
@Input() id?: string;
/**
* Label text displayed above the select input.
*/
@Input() label = '';
/**
* Custom CSS class to apply to the component container.
*/
@Input() customClass = '';
/**
* Placeholder text shown when no options are selected.
*/
@Input() placeholder = '';
/**
* External error message to display.
*/
@Input() externalError = '';
/**
* External icon to display.
*/
@Input() externalIcon = '';
/**
* Placeholder text for the search input.
*/
@Input() placeholderSearch = '';
/**
* Disables the select input when true.
*/
@Input() disabled = false;
/**
* Makes the select input read-only when true.
*/
@Input() readonly = false;
/**
* Marks the field as required when true.
*/
@Input() required = false;
/**
* Shows loading spinner when true.
*/
@Input() loading = false;
/**
* Enables form error messages when true.
*/
@Input() useFormErrors = true;
/**
* Shows error message container when true.
*/
@Input() errorSpan = true;
/**
* Background color for the select input.
*/
@Input() backgroundColor = '';
/**
* Border color for the select input.
*/
@Input() borderColor = '';
/**
* Color for the label text.
*/
@Input() labelColor = '';
/**
* Minimum characters required to trigger search.
*/
@Input() minCharactersToSearch = 0;
/**
* Debounce time (in ms) for search input.
*/
@Input() timeToChange = 800;
/**
* Array of available options for selection.
*/
@Input() options: Array<OptionMulti> = [];
/**
* Maximum number of items that can be selected.
*/
@Input() maxSelects?: number;
/**
* Minimum number of items required to be selected.
*/
@Input() minSelects?: number;
/**
* Shows selected items inside the input when true.
*/
@Input() showInside = true;
/**
* Hides the search input when true.
*/
@Input() hideSearch = false;
/**
* Tooltip message content.
*/
@Input() tooltipMessage = '';
/**
* Position of the tooltip relative to the label.
*/
@Input() tooltipPlacement: 'center top' | 'center bottom' | 'left center' | 'right center' = 'right center';
/**
* Color scheme for the tooltip.
*/
@Input() tooltipColor = 'inherit';
/**
* Icon to display with the tooltip.
*/
@Input() tooltipIcon = '';
/**
* Width of the component container.
* Can be any valid CSS width value (px, %, etc).
* Default: '100%'
*/
@Input() width = '100%';
/**
* Minimum width of the component container.
* Default: '200px'
*/
@Input() minWidth = '200px';
/**
* Width of the dropdown panel.
* Can be different from the trigger width.
* Default: '100%'
*/
@Input() dropdownWidth = '100%';
// #endregion
// #region Output Events
/**
* Emitted when the selected values change.
*/
@Output() valueChange: EventEmitter<Array<OptionMulti>> = new EventEmitter();
/**
* Emitted when the search text changes (with debounce).
*/
@Output() searchChange: EventEmitter<string> = new EventEmitter();
/**
* Emitted when the dropdown closes.
* Contains boolean indicating if selection changed.
*/
@Output() closeChange: EventEmitter<boolean> = new EventEmitter();
/**
* Emitted when validation status changes.
* True when valid, false when invalid.
*/
@Output() valid: EventEmitter<boolean> = new EventEmitter();
// #endregion
// #region Content Projection
/**
* Custom template for the label content.
*/
@ContentChild('labelTemplate') labelTemplate: TemplateRef<HTMLElement> | null = null;
/**
* Custom template to display when no options are available.
*/
@ContentChild('selectEmptyTemplate') selectEmptyTemplate: TemplateRef<HTMLElement> | null = null;
// #endregion
// #region Internal State
/**
* Flag indicating whether the rendering of the options list is active
* @internal
*/
renderOptionsList = false;
/**
* State that controls whether the dropdown is open or closed
* @internal
*/
open = false;
/**
* Current text used for search/filter
* @internal
*/
searchText = '';
/**
* Flag indicating whether the value has changed since the last opening
* @internal
*/
valueChanged = false;
/**
* Temporary flag for timeout control
* @internal
*/
timeouted = false;
/**
* Current error message or error boolean state
* @internal
*/
error: boolean | string = '';
/**
* Reference to the native element of the component
* @internal
*/
nativeElement: ElementRef;
/**
* Internal list of options after filters are applied
* @internal
*/
_options: Array<OptionMulti> = [];
/**
* Flag indicating whether the maximum number of selections has been reached
* @internal
*/
ismaxSelects = false;
/**
* Reference to the search timeout for debounce
* @internal
*/
timeoutInput!: ReturnType<typeof setTimeout>;
/**
* Dynamic height of the virtual scroll viewport
* Automatically calculated based on the number of options
* @internal
*/
cdkVirtualScrollViewportHeight = '305px';
/**
* Size of the items in the virtual scroll (in pixels)
* @internal
*/
cdkItemSize: string | null = '32';
// #endregion
/**
* Constructor for the SqSelectMultiComponent.
* Initializes the component with default values and dependencies.
* @param element - ElementRef for accessing native DOM element
* @param translate - Optional TranslateService for internationalization
* @param changeDetector - ChangeDetectorRef for manual change detection
*/
// #region Constructor
constructor(
public element: ElementRef,
@Optional() private translate: TranslateService,
private changeDetector: ChangeDetectorRef
) {
this.nativeElement = element.nativeElement;
}
// #endregion
// #region Lifecycle Methods
/**
* Angular lifecycle hook called when input properties change.
* @param changes - Object containing changed properties.
*/
async ngOnChanges(changes: SimpleChanges) {
if (this.open && changes.hasOwnProperty('options')) {
this.verifyNewOptions();
}
if (
changes.hasOwnProperty('value') ||
changes.hasOwnProperty('minSelects') ||
changes.hasOwnProperty('maxSelects')
) {
this.validate();
}
}
// #endregion
// #region Public Methods
/**
* Toggles the dropdown open/closed state.
* Handles animations and rendering of options list.
*/
async doDropDownAction() {
if (this.open) {
this.closeDropdown();
this.renderOptionsList = await new Promise<boolean>(resolve =>
setTimeout(() => {
resolve(false);
}, 300)
);
this.changeDetector.detectChanges();
} else {
this.verifyNewOptions();
this.renderOptionsList = true;
this.open = await new Promise<boolean>(resolve =>
setTimeout(() => {
resolve(true);
}, 100)
);
this.changeDetector.detectChanges();
}
}
/**
* Closes the dropdown and resets search state.
*/
closeDropdown() {
this.open = false;
this._options = [];
this.searchText = '';
this.closeChange.emit(this.valueChanged);
this.valueChanged = false;
}
/**
* Handles expanding/collapsing of hierarchical options.
* @param item - The option item to toggle
*/
handleCollapse(item: OptionMulti) {
item.open = !item.open;
if (item.children) {
if (this.options.find(option => option.open) || item.open) {
this.cdkItemSize = null;
} else {
this.cdkItemSize = '32';
}
}
}
// #endregion
// #region Selection Methods
/**
* Determines if an item exists in the selected values.
* @param item - The item to check
* @param value - Optional array to check against (defaults to current value)
* @returns True if the item is selected
*/
findItemInValue = useMemo((item: OptionMulti, value?: Array<OptionMulti>) => {
return !!value?.find(value => value.value === item.value);
});
/**
* Checks if any options have children (hierarchy).
* @param options - Array of options to check
* @returns True if any option has children
*/
verifyIfOptionsHasChildren = useMemo((options: OptionMulti[]) => {
return options.some(item => item.children?.length);
});
/**
* Checks if an item has any selected children.
* @param item - The parent item to check
* @param value - Optional array to check against (defaults to current value)
* @returns True if any child is selected
*/
verifyIfHasChildrenInValue = useMemo((item: OptionMulti, value?: Array<OptionMulti>) => {
if (item.children?.length) {
const hasAllChildren = item.children.every(child => this.findItemInValue(child, value));
if (hasAllChildren && !this.findItemInValue(item, value) && !this.timeouted) {
this.timeouted = true;
setTimeout(() => {
this.emit(item, true);
this.timeouted = false;
}, 0);
}
return !!item.children.find(child => this.findItemInValue(child, value));
}
return false;
});
/**
* Adds or removes an item from the selection.
* @param object - The option to select/deselect
* @param checked - True to select, false to deselect
*/
emit(object: OptionMulti, checked: boolean) {
if (checked) {
this.value?.push(object);
} else {
this.value = this.value?.filter(item => item.value !== object.value);
if (object.children?.length) {
object.children.forEach(item => {
this.value = this.value?.filter(child => child.value !== item.value);
});
}
}
this.valueChanged = true;
this.valueChange.emit(this.value);
this.validate();
}
// #endregion
// #region Validation
/**
* Validates the current selection state.
* Updates error messages and validity status.
*/
validate() {
if (this.externalError) {
this.error = false;
} else if (this.required && !this.value?.length) {
this.setError('forms.required');
this.valid.emit(false);
} else if (this.minSelects && this.value && this.value?.length < this.minSelects) {
this.setError('forms.minimumRequired', { minSelects: this.minSelects });
this.valid.emit(false);
} else if (this.maxSelects && this.value && this.value?.length === this.maxSelects) {
this.renderOptionsList = false;
this.ismaxSelects = true;
this.error = '';
this.valid.emit(true);
} else {
this.ismaxSelects = false;
this.error = '';
this.valid.emit(true);
}
}
/**
* Sets an error message using translation.
* @param key - Translation key for the error
* @param interpolateParams - Optional interpolation parameters
*/
async setError(key: string, interpolateParams: Object = {}) {
if (this.useFormErrors && this.translate) {
this.error = await this.translate.instant(key, interpolateParams);
}
}
// #endregion
// #region Search & Filtering
/**
* Handles search input changes with debounce.
* @param event - The new search text
*/
async modelChange(event: any) {
if (!this.minCharactersToSearch || !event.length || event.length >= this.minCharactersToSearch) {
clearTimeout(this.timeoutInput);
this.searchText =
(await new Promise<string>(
resolve =>
(this.timeoutInput = setTimeout(() => {
resolve(event);
}, this.timeToChange))
)) || '';
this.searchChange.emit(event);
this.changeDetector.detectChanges();
}
}
// #endregion
// #region Virtual Scroll Helpers
/**
* Updates options list and calculates virtual scroll viewport height.
* Adjusts based on number of options for optimal performance.
*/
verifyNewOptions() {
this._options = this.options;
if (!this._options.length) {
this.cdkVirtualScrollViewportHeight = '16px';
} else if (this._options.length < 15) {
this.cdkVirtualScrollViewportHeight = this._options.length * 32 + 'px';
} else {
this.cdkVirtualScrollViewportHeight = '305px';
}
}
/**
* TrackBy function for ngFor optimizations.
* @param index - Item index
* @param opt - Option item
* @returns Unique identifier for the item
*/
trackByOptValue: TrackByFunction<any> = useMemo((index, opt) => opt.value);
// #endregion
}
<div class="wrapper-all-inside-input {{ customClass }}" [style.width]="width" [style.min-width]="minWidth">
@if (label || labelTemplate || tooltipMessage) {
<label class="display-flex align-items-center" [ngClass]="{ readonly: readonly || ismaxSelects }" [for]="id">
@if (label && !labelTemplate) {
<div [ngStyle]="{ color: labelColor }" [innerHtml]="label | universalSafe"></div>
}
@if (labelTemplate) {
<span>
<ng-container *ngTemplateOutlet="labelTemplate"></ng-container>
</span>
}
@if (tooltipMessage) {
<sq-tooltip
class="ml-1"
[message]="tooltipMessage"
[placement]="tooltipPlacement"
[color]="tooltipColor"
[icon]="tooltipIcon"
></sq-tooltip>
}
</label>
}
<div
class="wrapper-select-multi"
[ngClass]="{
error: (externalError && externalError !== '') || (error && error !== ''),
disabled: disabled,
readonly: readonly || ismaxSelects,
loading: loading,
}"
>
<div
[class]="'input-fake col border-' + borderColor"
style="min-height: auto"
[ngStyle]="{ 'border-color': borderColor }"
[ngClass]="{
'no-label': !(label && label.length > 0),
'has-icon': error || externalError,
disabled: disabled,
readonly: readonly || ismaxSelects,
}"
[ngStyle]="{
'background-color': backgroundColor,
'border-color': borderColor,
}"
[clickOutsideEnabled]="open"
(clickOutside)="closeDropdown()"
>
<div
class="input-fake-content"
[ngClass]="{ disabled: disabled, readonly: readonly }"
(click)="doDropDownAction()"
>
@if (loading) {
<div class="loading-wrapper">
<sq-loader></sq-loader>
</div>
}
@if (!value?.length) {
<span>{{ placeholder }}</span>
<div class="input-fake-content-text">
<span class="selected-value">
{{ value[0]?.label }}
@if (value.length > 1) {
<span> {{ 'forms.more' | translateInternal | async }} {{ value.length - 1 }} </span>
}
</span>
</div>
}
@if (!loading) {
<i class="icon-down fas fa-chevron-down"> </i>
}
</div>
@if (!loading && !disabled && !readonly && !ismaxSelects && renderOptionsList) {
<div
id="sq-select-multi-tags-scroll"
class="input-window scrollbar"
[ngClass]="{
open: !loading && !disabled && !ismaxSelects && renderOptionsList && open,
}"
>
<div class="input-search">
<div class="wrapper-all-inside-input">
<div class="p-0 wrapper-input wrapper-input-squid text-ellipsisarea">
@if (!hideSearch) {
<input
[name]="name"
[id]="id"
[placeholder]="placeholderSearch || ('forms.search' | translateInternal | async) || ''"
class="col input"
[ngModel]="searchText"
(ngModelChange)="modelChange($event)"
/>
}
</div>
<span class="icon icon-external textarea-icon">
<i class="fas fa-search"></i>
</span>
</div>
</div>
<cdk-virtual-scroll-viewport
[itemSize]="cdkItemSize"
[ngStyle]="{ height: cdkVirtualScrollViewportHeight }"
class="list scrollbar"
>
<ng-container
*cdkVirtualFor="let opt of _options | search: searchText; i as index; trackBy: trackByOptValue"
>
<ng-template *ngTemplateOutlet="option; context: { opt: opt, i: index }"></ng-template>
</ng-container>
</cdk-virtual-scroll-viewport>
@if (!_options?.length) {
@if (!selectEmptyTemplate) {
<p class="mb-0 mt-3">
{{ 'forms.searchSelectEmpty' | translateInternal | async }}
</p>
} @else {
<span>
<ng-container *ngTemplateOutlet="selectEmptyTemplate"> </ng-container>
</span>
}
}
</div>
}
</div>
</div>
@if (errorSpan) {
<div
class="box-validation box-invalid show"
[ngClass]="{
'visibility-hidden-force':
((!externalError || externalError === '') && (!error || error === '')) || disabled || readonly,
}"
>
<i
[ngClass]="{
'visibility-hidden-force': !error && !externalError,
}"
class="fa-solid fa-triangle-exclamation"
>
</i>
{{ externalError ? externalError : '' }}
{{ error && !externalError ? error : '' }}
</div>
}
<ng-template #option let-opt="opt" let-i="i">
<li>
<div class="label">
@if (verifyIfOptionsHasChildren(options)) {
<i
class="icon-collapse fas fa-chevron-down"
[ngClass]="{ 'fa-rotate-by': !opt.open }"
[ngStyle]="{ color: !opt?.children?.length || opt?.disabled ? 'transparent' : '' }"
(click)="handleCollapse(opt)"
style="--fa-rotate-angle: -90deg"
></i>
}
<sq-selector
[style.minWidth]="'auto'"
[id]="(id || name) + '-checkbox-' + opt?.value + '-' + i"
[name]="name + '-checkbox'"
[value]="opt?.value"
[disabled]="opt?.disabled"
[checked]="findItemInValue(opt, value)"
[indeterminate]="verifyIfHasChildrenInValue(opt, value)"
(valueChange)="emit(opt, $event.checked)"
>
</sq-selector>
<span
class="text m-0 display-inline-block"
[ngClass]="{ 'cursor-pointer': opt?.children?.length }"
(click)="handleCollapse(opt)"
>
{{ opt?.label }}
</span>
</div>
@if (opt?.children?.length) {
<ul class="children" [ngClass]="{ open: !opt?.disabled && opt?.open }">
@for (
child of opt?.children | searchValidValues: searchText;
track trackByOptValue(j, child);
let j = $index
) {
<ng-template *ngTemplateOutlet="option; context: { opt: child, i: j }"></ng-template>
}
</ul>
}
</li>
</ng-template>
</div>
./sq-select-multi.component.scss
.wrapper-select-multi {
margin: 0;
padding: 0;
text-align: left;
display: flex;
flex-direction: column;
position: relative;
&.loading {
cursor: wait;
}
.input-fake-content {
min-height: 44px;
border: 1px solid var(--border_color);
background: var(--background);
padding: 0.75rem 1rem;
transition: var(--transition);
color: var(--text_color);
border-radius: 5px;
display: flex;
align-items: center;
position: relative;
.selected-value {
display: inline-flex;
align-items: center;
gap: 5px;
}
.additional-count {
background: var(--border_color);
border-radius: 10px;
padding: 0 6px;
font-size: 0.85rem;
}
.icon-down {
position: absolute;
right: 15px;
top: 50%;
transform: translateY(-50%);
font-size: 0.86rem;
}
&.readonly {
cursor: not-allowed;
pointer-events: none;
background-color: var(--color_border_input);
border-color: var(--color_border_input_disabled);
}
}
.loading-wrapper {
position: absolute;
right: 5px;
bottom: 10px;
}
.input-search {
display: inline-block;
position: sticky;
top: 0;
z-index: 1;
width: 100%;
padding-top: 1.1rem;
margin-bottom: 0.7rem;
background-color: var(--background_secondary);
.wrapper-all-inside-input {
.icon {
margin: 0;
font-size: 1rem;
font-weight: 700;
position: absolute;
right: 11px;
top: 27px;
}
.icon-external {
color: inherit !important;
}
}
}
.input-window {
width: 100%;
min-width: 100%;
position: absolute;
top: 100%;
max-height: 0;
height: 0;
transition: var(--transition);
overflow: hidden;
z-index: 1;
&.open {
height: auto;
max-height: 400px;
background: var(--background_secondary);
padding: 0 0.7rem 1.1rem;
overflow-y: auto;
box-shadow: var(--box_shadow);
}
}
.icon-collapse {
transition: var(--transition);
cursor: pointer;
padding: 0.35rem;
position: relative;
}
.list,
.children {
padding: 0;
list-style: none;
margin: 0;
width: 100%;
li {
display: grid;
.label {
margin: 0 0 0.5rem;
display: flex;
flex-wrap: nowrap;
align-items: baseline;
justify-content: flex-start;
transition: var(--transition);
}
}
}
.children {
max-height: 0;
overflow: hidden;
transition: var(--transition);
padding-left: 1.4rem;
&.open {
max-height: 9999px;
height: auto;
max-height: 400px;
}
.list {
overflow-y: auto;
max-height: 300px;
li {
padding: 8px 12px;
}
}
}
}