src/components/sq-select-multi-form-control/sq-select-multi-form-control.component.ts
Componente de select múltiplo com Reactive Forms. Unifica sq-select-multi e sq-select-multi-tags em um único componente.
Example :// Modo default (compacto)
<sq-select-multi-form-control
[label]="'Cidades'"
[options]="cities"
[formControl]="citiesControl"
></sq-select-multi-form-control>// Modo tags (tags removíveis)
<sq-select-multi-form-control
[label]="'Tags'"
[options]="tags"
[formControl]="tagsControl"
[displayMode]="'tags'"
></sq-select-multi-form-control>
OnInit
OnDestroy
OnChanges
| changeDetection | ChangeDetectionStrategy.OnPush |
| providers |
{
provide: NG_VALUE_ACCESSOR, useExisting: SqSelectMultiFormControlComponent, multi: true,
}
|
| selector | sq-select-multi-form-control |
| standalone | true |
| imports |
NgClass
NgStyle
NgTemplateOutlet
ReactiveFormsModule
ScrollingModule
SqLoaderComponent
SqTooltipComponent
SqSelectorComponent
SqClickOutsideDirective
SqDataTestDirective
UniversalSafePipe
|
| styleUrls | ./sq-select-multi-form-control.component.scss |
| templateUrl | ./sq-select-multi-form-control.component.html |
constructor()
|
|
Construtor do componente. |
| backgroundColor | |
Type : string
|
|
Default value : ''
|
|
|
Cor de fundo. |
|
| borderColor | |
Type : string
|
|
Default value : ''
|
|
|
Cor da borda. |
|
| displayMode | |
Type : "default" | "tags"
|
|
Default value : 'default'
|
|
|
Modo de exibição dos itens selecionados.
|
|
| dropdownWidth | |
Type : string
|
|
Default value : '100%'
|
|
|
Largura do dropdown. |
|
| fullOptionAsValue | |
Type : boolean
|
|
Default value : false
|
|
|
Se true, retorna o array de objetos OptionMulti completos como value. Se false (padrão), retorna apenas um array com os values das Options selecionadas. |
|
| hasMore | |
Type : boolean
|
|
Default value : false
|
|
|
Indica se há mais itens para carregar (infinity scroll). |
|
| hideSearch | |
Type : boolean
|
|
Default value : false
|
|
|
Oculta o campo de busca. |
|
| infiniteScroll | |
Type : boolean
|
|
Default value : false
|
|
|
Habilita infinity scroll. |
|
| labelColor | |
Type : string
|
|
Default value : ''
|
|
|
Cor da label. |
|
| loading | |
Type : boolean
|
|
Default value : false
|
|
|
Indica carregamento. |
|
| maxSelections | |
Type : number
|
|
|
Número máximo de seleções (apenas para controle visual, não valida). |
|
| minCharactersToSearch | |
Type : number
|
|
Default value : 0
|
|
|
Caracteres mínimos para busca. |
|
| minWidth | |
Type : string
|
|
Default value : '200px'
|
|
|
Largura mínima. |
|
| optionDataTest | |
Type : string
|
|
Default value : 'option-select-multi'
|
|
|
Data test para as opções. |
|
| options | |
Type : OptionMulti[]
|
|
Default value : []
|
|
|
Opções disponíveis. |
|
| searchable | |
Type : "local" | "remote"
|
|
|
Modo de busca: 'local' filtra client-side, 'remote' emite evento. |
|
| searchDebounce | |
Type : number
|
|
Default value : 300
|
|
|
Debounce da busca em ms. |
|
| searchPlaceholder | |
Type : string
|
|
Default value : 'Buscar...'
|
|
|
Placeholder do campo de busca. |
|
| selectHandleDataTest | |
Type : string
|
|
Default value : 'select-multi'
|
|
|
Data test para o handle do select. |
|
| selectInputDataTest | |
Type : string
|
|
Default value : 'input-select-multi'
|
|
|
Data test para o input de busca. |
|
| showInside | |
Type : boolean
|
|
Default value : true
|
|
|
Mostra itens selecionados dentro do input. |
|
| tagsMaxHeight | |
Type : string
|
|
Default value : '100px'
|
|
|
Altura máxima das tags (modo 'tags'). |
|
| trackByFn | |
Type : function
|
|
|
Função customizada para trackBy. |
|
| width | |
Type : string
|
|
Default value : '100%'
|
|
|
Largura do componente. |
|
| customClass | |
Type : string
|
|
Default value : ''
|
|
|
Inherited from
SqFormControlBaseDirective
|
|
|
Defined in
SqFormControlBaseDirective:55
|
|
|
Custom CSS class for the input element. |
|
| id | |
Type : string
|
|
|
Inherited from
SqFormControlBaseDirective
|
|
|
Defined in
SqFormControlBaseDirective:45
|
|
|
The id attribute for the input element. |
|
| label | |
Type : string
|
|
|
Inherited from
SqFormControlBaseDirective
|
|
|
Defined in
SqFormControlBaseDirective:50
|
|
|
An optional label for the input. |
|
| name | |
Type : string
|
|
Default value : `random-name-${(1 + Date.now() + Math.random()).toString().replace('.', '')}`
|
|
|
Inherited from
SqFormControlBaseDirective
|
|
|
Defined in
SqFormControlBaseDirective:40
|
|
|
The name attribute for the input element. |
|
| placeholder | |
Type : string
|
|
Default value : ''
|
|
|
Inherited from
SqFormControlBaseDirective
|
|
|
Defined in
SqFormControlBaseDirective:60
|
|
|
Placeholder text for the input element. |
|
| readonly | |
Type : boolean
|
|
Default value : false
|
|
|
Inherited from
SqFormControlBaseDirective
|
|
|
Defined in
SqFormControlBaseDirective:65
|
|
|
Flag to make the input element readonly. |
|
| tooltipColor | |
Type : string
|
|
Default value : 'inherit'
|
|
|
Inherited from
SqFormControlBaseDirective
|
|
|
Defined in
SqFormControlBaseDirective:80
|
|
|
Color of the tooltip. |
|
| tooltipIcon | |
Type : string
|
|
Default value : ''
|
|
|
Inherited from
SqFormControlBaseDirective
|
|
|
Defined in
SqFormControlBaseDirective:85
|
|
|
Icon for the tooltip. |
|
| tooltipMessage | |
Type : string
|
|
Default value : ''
|
|
|
Inherited from
SqFormControlBaseDirective
|
|
|
Defined in
SqFormControlBaseDirective:70
|
|
|
Tooltip message to display. |
|
| tooltipPlacement | |
Type : "center top" | "center bottom" | "left center" | "right center"
|
|
Default value : 'right center'
|
|
|
Inherited from
SqFormControlBaseDirective
|
|
|
Defined in
SqFormControlBaseDirective:75
|
|
|
Placement of the tooltip. |
|
| closeChange | |
Type : EventEmitter
|
|
|
Evento quando dropdown fecha. |
|
| loadMore | |
Type : EventEmitter
|
|
|
Evento para carregar mais itens. |
|
| removeTag | |
Type : EventEmitter
|
|
|
Evento quando uma tag é removida (modo 'tags'). |
|
| searchChange | |
Type : EventEmitter
|
|
|
Evento de busca (modo remote). |
|
| blurred | |
Type : EventEmitter<FocusEvent>
|
|
|
Inherited from
SqFormControlBaseDirective
|
|
|
Defined in
SqFormControlBaseDirective:100
|
|
|
Event emitter for blur events. |
|
| focused | |
Type : EventEmitter<FocusEvent>
|
|
|
Inherited from
SqFormControlBaseDirective
|
|
|
Defined in
SqFormControlBaseDirective:95
|
|
|
Event emitter for focus events. |
|
| valueChange | |
Type : EventEmitter<any>
|
|
|
Inherited from
SqFormControlBaseDirective
|
|
|
Defined in
SqFormControlBaseDirective:90
|
|
|
Event emitter for input value changes. |
|
| Private checkMaxSelections |
checkMaxSelections()
|
|
Verifica e atualiza estado de máximo de seleções.
Returns :
void
|
| closeDropdown |
closeDropdown()
|
|
Fecha o dropdown.
Returns :
void
|
| Private filterOptionsLocally | ||||||||
filterOptionsLocally(term: string)
|
||||||||
|
Filtra opções localmente pelo termo de busca.
Parameters :
Returns :
OptionMulti[]
Opções filtradas. |
| Private getOptionFakeId | ||||||||
getOptionFakeId(option: OptionMulti)
|
||||||||
|
Obtém ou gera um fakeId para a opção (para trackBy).
Parameters :
Returns :
string
FakeId da opção. |
| hasSelectedChildren | ||||||
hasSelectedChildren(option: OptionMulti)
|
||||||
|
Verifica se algum filho está selecionado.
Parameters :
Returns :
boolean
|
| isSelected | ||||||||
isSelected(option: OptionMulti)
|
||||||||
|
Verifica se uma opção está selecionada.
Parameters :
Returns :
boolean
true se a opção está selecionada. |
| ngOnChanges | ||||||||
ngOnChanges(changes: SimpleChanges)
|
||||||||
|
Detecta mudanças nos inputs.
Parameters :
Returns :
void
|
| ngOnDestroy |
ngOnDestroy()
|
|
Inherited from
SqFormControlBaseDirective
|
|
Defined in
SqFormControlBaseDirective:385
|
|
Cleanup do componente.
Returns :
void
|
| ngOnInit |
ngOnInit()
|
|
Inherited from
SqFormControlBaseDirective
|
|
Defined in
SqFormControlBaseDirective:363
|
|
Inicialização do componente.
Returns :
void
|
| onBlur | ||||||||
onBlur(event: FocusEvent)
|
||||||||
|
Inherited from
SqFormControlBaseDirective
|
||||||||
|
Defined in
SqFormControlBaseDirective:701
|
||||||||
|
Handler de blur.
Parameters :
Returns :
void
|
| onFocus | ||||||||
onFocus(event: FocusEvent)
|
||||||||
|
Inherited from
SqFormControlBaseDirective
|
||||||||
|
Defined in
SqFormControlBaseDirective:714
|
||||||||
|
Handler de focus.
Parameters :
Returns :
void
|
| onScroll | ||||||||
onScroll(event: Event)
|
||||||||
|
Handler de scroll para infinity scroll (lista simples).
Parameters :
Returns :
void
|
| onSearchInput | ||||||||
onSearchInput(event: Event)
|
||||||||
|
Handler para input de busca.
Parameters :
Returns :
void
|
| onVirtualScroll |
onVirtualScroll()
|
|
Handler de scroll para infinity scroll (virtual scroll).
Returns :
void
|
| Async openDropdown |
openDropdown()
|
|
Abre o dropdown.
Returns :
Promise<void>
|
| registerOnChange | ||||||||
registerOnChange(fn: (value: any) => void)
|
||||||||
|
Inherited from
SqFormControlBaseDirective
|
||||||||
|
Defined in
SqFormControlBaseDirective:429
|
||||||||
|
Registra callback de mudança de valor.
Parameters :
Returns :
void
|
| removeItem | |||||||||
removeItem(option: OptionMulti, event?: Event)
|
|||||||||
|
Remove uma tag (modo 'tags').
Parameters :
Returns :
void
|
| setDisabledState | ||||||||
setDisabledState(isDisabled: boolean)
|
||||||||
|
Inherited from
SqFormControlBaseDirective
|
||||||||
|
Defined in
SqFormControlBaseDirective:438
|
||||||||
|
Define estado disabled.
Parameters :
Returns :
void
|
| toggleCollapse | ||||||
toggleCollapse(option: OptionMulti)
|
||||||
|
Toggle para expandir/colapsar hierarquia.
Parameters :
Returns :
void
|
| Async toggleDropdown |
toggleDropdown()
|
|
Alterna o estado do dropdown (abre/fecha).
Returns :
Promise<void>
|
| toggleOption | ||||||||||||
toggleOption(option: OptionMulti, event?: literal type | boolean)
|
||||||||||||
|
Alterna a seleção de uma opção.
Parameters :
Returns :
void
|
| writeValue | ||||||||
writeValue(value: OptionMulti[] | any[] | null)
|
||||||||
|
Inherited from
SqFormControlBaseDirective
|
||||||||
|
Defined in
SqFormControlBaseDirective:398
|
||||||||
|
Escreve o valor no controle.
Parameters :
Returns :
void
|
| registerOnTouched | ||||||||
registerOnTouched(fn: any)
|
||||||||
|
Inherited from
SqFormControlBaseDirective
|
||||||||
|
Defined in
SqFormControlBaseDirective:161
|
||||||||
|
ControlValueAccessor: Registers a callback function that is called when the control is touched.
Parameters :
Returns :
void
|
| Protected watchValueChanges |
watchValueChanges()
|
|
Inherited from
SqFormControlBaseDirective
|
|
Defined in
SqFormControlBaseDirective:217
|
|
Watch for value changes and emit the value to the parent component. Subscribes to control.valueChanges with distinctUntilChanged to avoid duplicate emissions. Automatically unsubscribes when the component is destroyed.
Returns :
void
|
| Private cdr |
Default value : inject(ChangeDetectorRef)
|
|
Referência ao ChangeDetectorRef. |
| Private elementRef |
Default value : inject(ElementRef)
|
|
Referência ao ElementRef. |
| emptyTemplate |
Type : TemplateRef<HTMLElement> | null
|
Default value : null
|
Decorators :
@ContentChild('emptyTemplate')
|
|
Template customizado para estado vazio. |
| Private fakeIdCounter |
Type : number
|
Default value : 0
|
|
Contador para gerar fakeIds únicos. |
| filteredOptions |
Type : OptionMulti[]
|
Default value : []
|
|
Opções filtradas. |
| isMaxSelections |
Default value : false
|
|
Atingiu máximo de seleções. |
| isOpen |
Default value : false
|
|
Dropdown aberto. |
| labelTemplate |
Type : TemplateRef<HTMLElement> | null
|
Default value : null
|
Decorators :
@ContentChild('labelTemplate')
|
|
Template customizado para o label. |
| Private optionFakeIdMap |
Default value : new WeakMap<OptionMulti, string>()
|
|
Mapa de fakeIds para trackBy. |
| optionTemplate |
Type : TemplateRef<> | null
|
Default value : null
|
Decorators :
@ContentChild('optionTemplate')
|
|
Template customizado para cada opção no dropdown. |
| renderDropdown |
Default value : false
|
|
Renderiza dropdown. |
| Private searchSubject |
Default value : new Subject<string>()
|
|
Subject para busca. |
| searchText |
Type : string
|
Default value : ''
|
|
Texto de busca. |
| selectedTemplate |
Type : TemplateRef<> | null
|
Default value : null
|
Decorators :
@ContentChild('selectedTemplate')
|
|
Template customizado para exibição dos itens selecionados (modo default). |
| tagTemplate |
Type : TemplateRef<> | null
|
Default value : null
|
Decorators :
@ContentChild('tagTemplate')
|
|
Template customizado para cada tag (modo tags). |
| trackByValue | ||||||
Default value : () => {...}
|
||||||
|
Função trackBy para ngFor. |
||||||
|
Parameters :
|
| valueChanged |
Default value : false
|
|
Valor mudou desde abertura. |
| control |
Default value : new FormControl<any>(null)
|
|
Inherited from
SqFormControlBaseDirective
|
|
Defined in
SqFormControlBaseDirective:105
|
|
Internal FormControl for managing the input value and state. |
| Protected destroy$ |
Default value : new Subject<void>()
|
|
Inherited from
SqFormControlBaseDirective
|
|
Defined in
SqFormControlBaseDirective:110
|
|
Subject for managing subscriptions. |
| Protected onChange |
Type : function
|
Default value : () => {...}
|
|
Inherited from
SqFormControlBaseDirective
|
|
Defined in
SqFormControlBaseDirective:117
|
|
ControlValueAccessor callback function called when the value changes. Registered via registerOnChange(). |
| Protected onTouched |
Type : function
|
Default value : () => {...}
|
|
Inherited from
SqFormControlBaseDirective
|
|
Defined in
SqFormControlBaseDirective:124
|
|
ControlValueAccessor callback function called when the control is touched. Registered via registerOnTouched(). |
| value |
getvalue()
|
|
Retorna o valor atual do controle.
Returns :
OptionMulti[]
|
| displayOptions |
getdisplayOptions()
|
|
Retorna as opções a serem exibidas (filtradas ou não).
Returns :
OptionMulti[]
|
| summaryText |
getsummaryText()
|
|
Retorna o texto resumido para exibição (modo default).
Returns :
string
|
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ContentChild,
ElementRef,
EventEmitter,
inject,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
TemplateRef,
} from '@angular/core';
import { NgClass, NgStyle, NgTemplateOutlet } from '@angular/common';
import { NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { Subject, debounceTime, distinctUntilChanged, takeUntil } from 'rxjs';
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 { SqClickOutsideDirective } from '../../directives/sq-click-outside/sq-click-outside.directive';
import { SqDataTestDirective } from '../../directives/sq-data-test/sq-data-test.directive';
import { UniversalSafePipe } from '../../pipes/universal-safe/universal-safe.pipe';
import { SqFormControlBaseDirective } from '../../directives/sq-form-control-base';
/**
* Componente de select múltiplo com Reactive Forms.
* Unifica sq-select-multi e sq-select-multi-tags em um único componente.
*
* @example
* // Modo default (compacto)
* <sq-select-multi-form-control
* [label]="'Cidades'"
* [options]="cities"
* [formControl]="citiesControl"
* ></sq-select-multi-form-control>
*
* @example
* // Modo tags (tags removíveis)
* <sq-select-multi-form-control
* [label]="'Tags'"
* [options]="tags"
* [formControl]="tagsControl"
* [displayMode]="'tags'"
* ></sq-select-multi-form-control>
*/
@Component({
selector: 'sq-select-multi-form-control',
templateUrl: './sq-select-multi-form-control.component.html',
styleUrls: ['./sq-select-multi-form-control.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
NgClass,
NgStyle,
NgTemplateOutlet,
ReactiveFormsModule,
ScrollingModule,
SqLoaderComponent,
SqTooltipComponent,
SqSelectorComponent,
SqClickOutsideDirective,
SqDataTestDirective,
UniversalSafePipe,
],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: SqSelectMultiFormControlComponent,
multi: true,
},
],
})
export class SqSelectMultiFormControlComponent
extends SqFormControlBaseDirective
implements OnInit, OnDestroy, OnChanges
{
// ============================================================
// Inputs - Aparência
// ============================================================
/**
* Modo de exibição dos itens selecionados.
* - 'default': Compacto, mostra "Item 1 +N mais"
* - 'tags': Tags individuais removíveis com X
*/
@Input() displayMode: 'default' | 'tags' = 'default';
/**
* Placeholder do campo de busca.
*/
@Input() searchPlaceholder = 'Buscar...';
/**
* Cor de fundo.
*/
@Input() backgroundColor = '';
/**
* Cor da borda.
*/
@Input() borderColor = '';
/**
* Cor da label.
*/
@Input() labelColor = '';
/**
* Largura do componente.
*/
@Input() width = '100%';
/**
* Largura mínima.
*/
@Input() minWidth = '200px';
/**
* Largura do dropdown.
*/
@Input() dropdownWidth = '100%';
/**
* Altura máxima das tags (modo 'tags').
*/
@Input() tagsMaxHeight = '100px';
// ============================================================
// Inputs - Comportamento
// ============================================================
/**
* Mostra itens selecionados dentro do input.
*/
@Input() showInside = true;
/**
* Oculta o campo de busca.
*/
@Input() hideSearch = false;
/**
* Número máximo de seleções (apenas para controle visual, não valida).
*/
@Input() maxSelections?: number;
/**
* Caracteres mínimos para busca.
*/
@Input() minCharactersToSearch = 0;
/**
* Debounce da busca em ms.
*/
@Input() searchDebounce = 300;
/**
* Opções disponíveis.
*/
@Input() options: OptionMulti[] = [];
/**
* Indica carregamento.
*/
@Input() loading = false;
/**
* Modo de busca: 'local' filtra client-side, 'remote' emite evento.
*/
@Input() searchable?: 'local' | 'remote';
/**
* Indica se há mais itens para carregar (infinity scroll).
*/
@Input() hasMore = false;
/**
* Habilita infinity scroll.
*/
@Input() infiniteScroll = false;
/**
* Função customizada para trackBy.
*/
@Input() trackByFn?: (index: number, option: OptionMulti) => unknown;
/**
* Se true, retorna o array de objetos OptionMulti completos como value.
* Se false (padrão), retorna apenas um array com os values das Options selecionadas.
*/
@Input() fullOptionAsValue = false;
// ============================================================
// Inputs - Data Test
// ============================================================
/**
* Data test para o handle do select.
*/
@Input() selectHandleDataTest = 'select-multi';
/**
* Data test para o input de busca.
*/
@Input() selectInputDataTest = 'input-select-multi';
/**
* Data test para as opções.
*/
@Input() optionDataTest = 'option-select-multi';
// ============================================================
// Outputs
// ============================================================
/**
* Evento de busca (modo remote).
*/
@Output() searchChange = new EventEmitter<string>();
/**
* Evento para carregar mais itens.
*/
@Output() loadMore = new EventEmitter<void>();
/**
* Evento quando dropdown fecha.
*/
@Output() closeChange = new EventEmitter<boolean>();
/**
* Evento quando uma tag é removida (modo 'tags').
*/
@Output() removeTag = new EventEmitter<OptionMulti>();
// ============================================================
// Templates
// ============================================================
/**
* Template customizado para o label.
*/
@ContentChild('labelTemplate') labelTemplate: TemplateRef<HTMLElement> | null = null;
/**
* Template customizado para cada opção no dropdown.
*/
@ContentChild('optionTemplate') optionTemplate: TemplateRef<unknown> | null = null;
/**
* Template customizado para exibição dos itens selecionados (modo default).
*/
@ContentChild('selectedTemplate') selectedTemplate: TemplateRef<unknown> | null = null;
/**
* Template customizado para estado vazio.
*/
@ContentChild('emptyTemplate') emptyTemplate: TemplateRef<HTMLElement> | null = null;
/**
* Template customizado para cada tag (modo tags).
*/
@ContentChild('tagTemplate') tagTemplate: TemplateRef<unknown> | null = null;
// ============================================================
// Estado interno
// ============================================================
/**
* Dropdown aberto.
*/
isOpen = false;
/**
* Renderiza dropdown.
*/
renderDropdown = false;
/**
* Texto de busca.
*/
searchText = '';
/**
* Opções filtradas.
*/
filteredOptions: OptionMulti[] = [];
/**
* Valor mudou desde abertura.
*/
valueChanged = false;
/**
* Atingiu máximo de seleções.
*/
isMaxSelections = false;
/**
* Subject para busca.
*/
private searchSubject = new Subject<string>();
/**
* Mapa de fakeIds para trackBy.
*/
private optionFakeIdMap = new WeakMap<OptionMulti, string>();
/**
* Contador para gerar fakeIds únicos.
*/
private fakeIdCounter = 0;
/**
* Referência ao ChangeDetectorRef.
*/
private cdr = inject(ChangeDetectorRef);
/**
* Referência ao ElementRef.
*/
private elementRef = inject(ElementRef);
/**
* Construtor do componente.
*/
constructor() {
super();
// Inicializa o control com array vazio em vez de string vazia
this.control.setValue([] as any, { emitEvent: false });
// Subscription para controle interno
this.control.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((value: any) => {
const options = (value as OptionMulti[]) || [];
// Se fullOptionAsValue for true, retorna o array de objetos completos
// Caso contrário, retorna apenas um array com os values das Options
if (this.fullOptionAsValue) {
this.onChange(options);
} else {
this.onChange(options.map(opt => opt.value));
}
this.checkMaxSelections();
});
// Subscription para busca com debounce
this.searchSubject
.pipe(debounceTime(this.searchDebounce), distinctUntilChanged(), takeUntil(this.destroy$))
.subscribe(term => {
if (this.searchable === 'remote') {
this.searchChange.emit(term);
}
this.cdr.markForCheck();
});
}
/**
* Inicialização do componente.
*/
override ngOnInit(): void {
super.ngOnInit();
// Inicializa filteredOptions com options
this.filteredOptions = this.options;
}
/**
* Detecta mudanças nos inputs.
*
* @param changes - Mudanças detectadas.
*/
ngOnChanges(changes: SimpleChanges): void {
// Atualiza filteredOptions quando options mudam (busca remota)
if (changes['options'] && !changes['options'].firstChange) {
this.filteredOptions = this.options;
this.cdr.markForCheck();
}
}
/**
* Cleanup do componente.
*/
override ngOnDestroy(): void {
super.ngOnDestroy();
}
// ============================================================
// ControlValueAccessor (sobrescreve métodos do base class)
// ============================================================
/**
* Escreve o valor no controle.
*
* @param value - Valor a ser escrito (pode ser array de OptionMulti ou array de values).
*/
override writeValue(value: OptionMulti[] | any[] | null): void {
if (!value || value.length === 0) {
this.control.setValue([] as any, { emitEvent: false });
this.checkMaxSelections();
this.cdr.markForCheck();
return;
}
// Verifica se é um array de objetos OptionMulti (tem propriedade 'value' e 'label')
const isOptionMultiArray =
typeof value[0] === 'object' && value[0] !== null && 'value' in value[0] && 'label' in value[0];
// Se fullOptionAsValue for false e o valor for um array de primitivos,
// precisa converter para array de OptionMulti procurando nas options
if (!this.fullOptionAsValue && !isOptionMultiArray) {
const options = this.options.filter(opt => (value as any[]).includes(opt.value));
this.control.setValue(options as any, { emitEvent: false });
} else {
// Se fullOptionAsValue for true ou já for array de objetos OptionMulti
this.control.setValue(value as OptionMulti[] as any, { emitEvent: false });
}
this.checkMaxSelections();
this.cdr.markForCheck();
}
/**
* Registra callback de mudança de valor.
*
* @param fn - Callback.
*/
override registerOnChange(fn: (value: any) => void): void {
this.onChange = fn;
}
/**
* Define estado disabled.
*
* @param isDisabled - Se está desabilitado.
*/
override setDisabledState(isDisabled: boolean): void {
super.setDisabledState(isDisabled);
this.cdr.markForCheck();
}
// ============================================================
// Getters
// ============================================================
/**
* Retorna o valor atual do controle.
*/
override get value(): OptionMulti[] {
return (this.control.value as unknown as OptionMulti[]) || [];
}
/**
* Retorna as opções a serem exibidas (filtradas ou não).
*/
get displayOptions(): OptionMulti[] {
if (this.searchable === 'local' && this.searchText) {
return this.filterOptionsLocally(this.searchText);
}
return this.filteredOptions;
}
/**
* Retorna o texto resumido para exibição (modo default).
*/
get summaryText(): string {
const values = this.value;
if (!values.length) return '';
if (values.length === 1) return values[0].label;
return `${values[0].label} +${values.length - 1}`;
}
// ============================================================
// Métodos públicos - Dropdown
// ============================================================
/**
* Alterna o estado do dropdown (abre/fecha).
*/
async toggleDropdown(): Promise<void> {
if (this.disabled || this.readonly || this.isMaxSelections) return;
if (this.isOpen) {
this.closeDropdown();
} else {
await this.openDropdown();
}
}
/**
* Abre o dropdown.
*/
async openDropdown(): Promise<void> {
this.renderDropdown = true;
this.filteredOptions = this.options;
this.cdr.detectChanges();
// Se modo remote e lista vazia, emite busca inicial (primeira página)
if (this.searchable === 'remote' && this.options.length === 0) {
this.searchChange.emit('');
}
await new Promise(resolve => setTimeout(resolve, 50));
this.isOpen = true;
this.cdr.detectChanges();
}
/**
* Fecha o dropdown.
*/
closeDropdown(): void {
this.isOpen = false;
this.searchText = '';
this.closeChange.emit(this.valueChanged);
this.valueChanged = false;
this.cdr.detectChanges();
setTimeout(() => {
this.renderDropdown = false;
this.cdr.detectChanges();
}, 200);
}
// ============================================================
// Métodos públicos - Seleção
// ============================================================
/**
* Verifica se uma opção está selecionada.
*
* @param option - A opção a verificar.
* @returns true se a opção está selecionada.
*/
isSelected(option: OptionMulti): boolean {
return this.value.some(v => v.value === option.value);
}
/**
* Alterna a seleção de uma opção.
*
* @param option - A opção a ser alternada.
* @param event - Evento opcional com estado checked.
*/
toggleOption(option: OptionMulti, event?: { value: boolean; checked: boolean } | boolean): void {
if (option.disabled) return;
// Normaliza o evento - se não fornecido, inverte o estado atual
let checked: boolean;
if (event === undefined) {
checked = !this.isSelected(option);
} else {
checked = typeof event === 'boolean' ? event : event.checked;
}
let newValue = [...this.value];
if (checked) {
if (!this.isSelected(option)) {
newValue.push(option);
}
// Adiciona filhos se existirem
if (option.children?.length) {
option.children.forEach(child => {
if (!newValue.some(v => v.value === child.value)) {
newValue.push(child);
}
});
}
} else {
newValue = newValue.filter(v => v.value !== option.value);
// Remove filhos se existirem
if (option.children?.length) {
const childValues = option.children.map(c => c.value);
newValue = newValue.filter(v => !childValues.includes(v.value));
}
}
this.control.setValue(newValue as any);
this.valueChanged = true;
this.cdr.markForCheck();
}
/**
* Remove uma tag (modo 'tags').
*/
removeItem(option: OptionMulti, event?: Event): void {
event?.stopPropagation();
if (this.readonly || this.disabled) return;
let newValue = this.value.filter(v => v.value !== option.value);
// Remove filhos também
if (option.children?.length) {
const childValues = option.children.map(c => c.value);
newValue = newValue.filter(v => !childValues.includes(v.value));
}
this.control.setValue(newValue as any);
this.removeTag.emit(option);
}
/**
* Verifica se algum filho está selecionado.
*/
hasSelectedChildren(option: OptionMulti): boolean {
if (!option.children?.length) return false;
return option.children.some(child => this.isSelected(child));
}
/**
* Toggle para expandir/colapsar hierarquia.
*/
toggleCollapse(option: OptionMulti): void {
option.open = !option.open;
}
// ============================================================
// Métodos públicos - Busca
// ============================================================
/**
* Handler para input de busca.
*
* @param event - Evento de input.
*/
onSearchInput(event: Event): void {
const input = event.target as HTMLInputElement;
const term = input.value;
this.searchText = term;
if (this.minCharactersToSearch && term.length < this.minCharactersToSearch) {
return;
}
if (this.searchable === 'local') {
this.filteredOptions = this.filterOptionsLocally(term);
this.cdr.markForCheck();
} else if (this.searchable === 'remote') {
this.searchSubject.next(term);
}
}
// ============================================================
// Métodos públicos - Scroll
// ============================================================
/**
* Handler de scroll para infinity scroll (lista simples).
*
* @param event - Evento de scroll.
*/
onScroll(event: Event): void {
if (!this.infiniteScroll || !this.hasMore || this.loading) return;
const element = event.target as HTMLElement;
const scrollBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
if (scrollBottom < 100) {
this.loadMore.emit();
}
}
/**
* Handler de scroll para infinity scroll (virtual scroll).
*/
onVirtualScroll(): void {
if (!this.infiniteScroll || !this.hasMore || this.loading) return;
const viewport = this.elementRef.nativeElement.querySelector('cdk-virtual-scroll-viewport');
if (viewport) {
const scrollBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
if (scrollBottom < 100) {
this.loadMore.emit();
}
}
}
/**
* Função trackBy para ngFor.
*
* @param index - Índice do item.
* @param option - Opção.
* @returns Identificador único da opção.
*/
trackByValue = (index: number, option: OptionMulti): unknown => {
if (this.trackByFn) {
return this.trackByFn(index, option);
}
return this.getOptionFakeId(option);
};
// ============================================================
// Métodos públicos - Eventos
// ============================================================
/**
* Handler de blur.
*
* @param event - Evento de blur.
*/
override onBlur(event: FocusEvent): void {
const relatedTarget = event.relatedTarget as HTMLElement;
if (relatedTarget && this.elementRef.nativeElement.contains(relatedTarget)) {
return;
}
super.onBlur(event);
}
/**
* Handler de focus.
*
* @param event - Evento de focus.
*/
override onFocus(event: FocusEvent): void {
super.onFocus(event);
}
// ============================================================
// Métodos privados
// ============================================================
/**
* Filtra opções localmente pelo termo de busca.
*
* @param term - Termo de busca.
* @returns Opções filtradas.
*/
private filterOptionsLocally(term: string): OptionMulti[] {
if (!term) return this.options;
const lowerTerm = term.toLowerCase();
return this.options.filter(opt => {
const matchesLabel = opt.label.toLowerCase().includes(lowerTerm);
const matchesChildren = opt.children?.some(child => child.label.toLowerCase().includes(lowerTerm));
return matchesLabel || matchesChildren;
});
}
/**
* Verifica e atualiza estado de máximo de seleções.
*/
private checkMaxSelections(): void {
if (this.maxSelections) {
this.isMaxSelections = this.value.length >= this.maxSelections;
if (this.isMaxSelections && this.isOpen) {
this.closeDropdown();
}
} else {
this.isMaxSelections = false;
}
}
/**
* Obtém ou gera um fakeId para a opção (para trackBy).
*
* @param option - Opção.
* @returns FakeId da opção.
*/
private getOptionFakeId(option: OptionMulti): string {
let fakeId = this.optionFakeIdMap.get(option);
if (!fakeId) {
fakeId = `option-${++this.fakeIdCounter}`;
this.optionFakeIdMap.set(option, fakeId);
}
return fakeId;
}
}
<div class="wrapper-all-inside-input {{ customClass }}">
@if (label?.length || labelTemplate || tooltipMessage) {
<label
class="display-flex align-items-center"
[ngClass]="{
readonly: readonly || isMaxSelections,
}"
[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>
}
<!-- Container principal -->
<div
class="wrapper-select-multi"
[ngClass]="{
error: false,
disabled: disabled,
readonly: readonly || isMaxSelections,
loading: loading,
}"
>
<div
[class]="'input-fake col border-' + (borderColor || '')"
style="min-height: auto"
[ngStyle]="{
'background-color': backgroundColor,
'border-color': borderColor,
}"
[ngClass]="{
'no-label': !(label?.length || labelTemplate),
'has-icon': false,
disabled: disabled,
readonly: readonly || isMaxSelections,
}"
[dataTest]="selectHandleDataTest"
[clickOutsideEnabled]="isOpen"
(clickOutside)="closeDropdown()"
>
<div
class="input-fake-content"
[ngClass]="{
disabled: disabled,
readonly: readonly,
}"
[style.maxHeight]="displayMode === 'tags' ? tagsMaxHeight : 'auto'"
(click)="toggleDropdown()"
>
@if (loading) {
<div class="loading-wrapper">
<sq-loader></sq-loader>
</div>
}
@if (!value.length || !showInside) {
<span>{{ placeholder }}</span>
}
@if (showInside && value.length) {
<!-- Modo Default: exibição compacta -->
@if (displayMode === 'default') {
@if (selectedTemplate) {
<ng-container
*ngTemplateOutlet="selectedTemplate; context: { $implicit: value, selected: value, count: value.length }"
></ng-container>
} @else {
<span class="selected-summary">{{ summaryText }}</span>
}
}
<!-- Modo Tags: tags individuais -->
@if (displayMode === 'tags') {
<div class="input-fake-content-text">
@for (opt of value; track trackByValue($index, opt)) {
@if (tagTemplate) {
<ng-container
*ngTemplateOutlet="tagTemplate; context: { $implicit: opt, option: opt, remove: removeItem.bind(this) }"
></ng-container>
} @else {
<span class="tag" (click)="removeItem(opt, $event)">
{{ opt.label }}
@if (!readonly) {
<i class="fas fa-times" [style.minWidth]="'auto'"></i>
}
</span>
}
}
</div>
}
}
@if (value.length) {
<span class="badge">{{ value.length }}</span>
}
@if (!loading) {
<i class="icon-down fas fa-chevron-down"></i>
}
</div>
@if (!loading && !disabled && !readonly && !isMaxSelections && renderDropdown) {
<div
id="sq-select-multi-tags-scroll"
class="input-window scrollbar"
[ngClass]="{
open: !loading && !disabled && !isMaxSelections && renderDropdown && isOpen,
}"
>
<div class="input-search">
<div class="wrapper-all-inside-input">
<div class="p-0 wrapper-input wrapper-input-squid text-ellipsisarea">
<input
[dataTest]="selectInputDataTest"
[name]="name"
[id]="id"
[placeholder]="searchPlaceholder || 'Buscar...'"
class="col input"
[value]="searchText"
(input)="onSearchInput($event)"
/>
</div>
<span class="icon icon-external textarea-icon"><i class="fas fa-search"></i></span>
</div>
</div>
<cdk-virtual-scroll-viewport
[itemSize]="32"
[ngStyle]="{ height: '305px' }"
class="list scrollbar"
>
<ng-container
*cdkVirtualFor="let opt of displayOptions; i as index; trackBy: trackByValue"
>
<ng-template *ngTemplateOutlet="option; context: { opt: opt, i: index }"></ng-template>
</ng-container>
</cdk-virtual-scroll-viewport>
@if (!displayOptions?.length) {
@if (!emptyTemplate) {
<p class="mb-0 mt-3">
Nenhuma opção encontrada
</p>
}
@if (emptyTemplate) {
<span>
<ng-container *ngTemplateOutlet="emptyTemplate"></ng-container>
</span>
}
}
</div>
}
</div>
</div>
<!-- Template de opção (recursivo para hierarquia) -->
<ng-template #option let-opt="opt" let-i="i">
<li>
<div class="label">
<i
class="icon-collapse fas fa-chevron-down"
[ngClass]="{ 'fa-rotate-by': !opt.open }"
[ngStyle]="{ color: !opt?.children?.length || opt?.disabled ? 'transparent' : '' }"
(click)="toggleCollapse(opt)"
style="--fa-rotate-angle: -90deg"
></i>
<sq-selector
[dataTest]="optionDataTest + '-' + (i || opt.value)"
[style.minWidth]="'auto'"
[id]="(id || name) + '-checkbox-' + opt?.value + '-' + i"
[name]="name + '-checkbox'"
[value]="opt?.value"
[disabled]="opt?.disabled"
[checked]="isSelected(opt)"
[indeterminate]="hasSelectedChildren(opt) && !isSelected(opt)"
(valueChange)="toggleOption(opt, $event)"
></sq-selector>
<span
class="text m-0 display-inline-block"
[ngClass]="{ 'cursor-pointer': opt?.children?.length }"
(click)="toggleCollapse(opt)"
>{{ opt?.label }}</span
>
</div>
@if (opt?.children?.length) {
<ul class="children" [ngClass]="{ open: !opt?.disabled && opt?.open }">
@for (
child of opt?.children;
track trackByValue(j, child);
let j = $index
) {
<ng-template *ngTemplateOutlet="option; context: { opt: child, i: j }"></ng-template>
}
</ul>
}
</li>
</ng-template>
</div>
./sq-select-multi-form-control.component.scss
// Estilos para sq-select-multi-form-control (compatível com sq-select-multi-tags)
.wrapper-all-inside-input {
position: relative;
display: block;
}
.wrapper-select-multi {
margin: 0;
padding: 0;
text-align: left;
display: flex;
flex-direction: column;
position: relative;
&.loading {
cursor: wait;
}
.input-fake-content-text {
display: flex;
justify-content: flex-start;
align-items: center;
flex-wrap: wrap;
gap: 5px;
max-width: calc(100% - 40px);
min-height: 51px;
.tag {
margin: 0;
border: 1px solid var(--border_color);
border-radius: 5px;
padding: 0.2rem 0.35rem;
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
transition: var(--transition);
background: var(--background_secondary);
&:hover {
background: var(--border_color);
}
}
}
.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;
overflow-x: auto;
.icon-down {
right: 20px;
bottom: calc(50% - 6px);
position: absolute;
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;
}
.badge {
position: absolute;
bottom: 15px;
right: 20px;
margin: 0;
border-radius: 50%;
background-color: var(--border_color);
color: var(--black);
font-size: 0.86rem;
font-weight: 700;
padding: 0 0.35rem;
border-radius: 50%;
min-width: 20px;
height: 20px;
line-height: 20px;
text-align: center;
transition: var(--transition);
}
.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: 16px;
}
.icon-external {
color: inherit !important;
}
}
}
.input-window {
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;
}
}
}