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>
ControlValueAccessor
Validator
OnInit
OnChanges
OnDestroy
| changeDetection | ChangeDetectionStrategy.OnPush |
| providers |
{
provide: NG_VALUE_ACCESSOR, useExisting: SqSelectMultiFormControlComponent, multi: true,
}
{
provide: NG_VALIDATORS, useExisting: SqSelectMultiFormControlComponent, multi: true,
}
|
| selector | sq-select-multi-form-control |
| standalone | true |
| imports |
NgClass
NgStyle
NgTemplateOutlet
AsyncPipe
ReactiveFormsModule
ScrollingModule
SqLoaderComponent
SqTooltipComponent
SqSelectorComponent
SqClickOutsideDirective
SqDataTestDirective
|
| 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. |
|
| customClass | |
Type : string
|
|
Default value : ''
|
|
|
Classe CSS customizada. |
|
| disabled | |
Type : boolean
|
|
Default value : false
|
|
|
Desabilita o componente. |
|
| displayMode | |
Type : "default" | "tags"
|
|
Default value : 'default'
|
|
|
Modo de exibição dos itens selecionados.
|
|
| dropdownWidth | |
Type : string
|
|
Default value : '100%'
|
|
|
Largura do dropdown. |
|
| 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. |
|
| id | |
Type : string
|
|
Default value : `select-multi-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
|
|
ID do elemento. |
|
| infiniteScroll | |
Type : boolean
|
|
Default value : false
|
|
|
Habilita infinity scroll. |
|
| label | |
Type : string
|
|
Default value : ''
|
|
|
Label do campo. |
|
| 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. |
|
| minCharactersToSearch | |
Type : number
|
|
Default value : 0
|
|
|
Caracteres mínimos para busca. |
|
| minSelections | |
Type : number
|
|
|
Número mínimo de seleções. |
|
| minWidth | |
Type : string
|
|
Default value : '200px'
|
|
|
Largura mínima. |
|
| name | |
Type : string
|
|
Default value : ''
|
|
|
Nome do campo. |
|
| optionDataTest | |
Type : string
|
|
Default value : 'option-select-multi'
|
|
|
Data test para as opções. |
|
| options | |
Type : OptionMulti[]
|
|
Default value : []
|
|
|
Opções disponíveis. |
|
| placeholder | |
Type : string
|
|
Default value : ''
|
|
|
Placeholder quando nenhum item selecionado. |
|
| readonly | |
Type : boolean
|
|
Default value : false
|
|
|
Modo somente leitura. |
|
| 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'). |
|
| tooltipColor | |
Type : string
|
|
Default value : 'inherit'
|
|
|
Cor do tooltip. |
|
| tooltipIcon | |
Type : string
|
|
Default value : ''
|
|
|
Ícone do tooltip. |
|
| tooltipMessage | |
Type : string
|
|
Default value : ''
|
|
|
Mensagem do tooltip. |
|
| tooltipPlacement | |
Type : "center top" | "center bottom" | "left center" | "right center"
|
|
Default value : 'right center'
|
|
|
Posição do tooltip. |
|
| trackByFn | |
Type : function
|
|
|
Função customizada para trackBy. |
|
| width | |
Type : string
|
|
Default value : '100%'
|
|
|
Largura do componente. |
|
| blurred | |
Type : EventEmitter
|
|
|
Evento de blur. |
|
| closeChange | |
Type : EventEmitter
|
|
|
Evento quando dropdown fecha. |
|
| focused | |
Type : EventEmitter
|
|
|
Evento de foco. |
|
| 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). |
|
| 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()
|
|
Cleanup do componente.
Returns :
void
|
| ngOnInit |
ngOnInit()
|
|
Inicialização do componente.
Returns :
void
|
| onBlur | ||||||||
onBlur(event: FocusEvent)
|
||||||||
|
Handler de blur.
Parameters :
Returns :
void
|
| onFocus | ||||||||
onFocus(event: FocusEvent)
|
||||||||
|
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: OptionMulti[]) => void)
|
||||||||
|
Registra callback de mudança de valor.
Parameters :
Returns :
void
|
| registerOnTouched | ||||||||
registerOnTouched(fn: () => void)
|
||||||||
|
Registra callback de touched.
Parameters :
Returns :
void
|
| registerOnValidatorChange | ||||||||
registerOnValidatorChange(fn: () => void)
|
||||||||
|
Registra callback para mudança de validação.
Parameters :
Returns :
void
|
| removeItem | |||||||||
removeItem(option: OptionMulti, event?: Event)
|
|||||||||
|
Remove uma tag (modo 'tags').
Parameters :
Returns :
void
|
| setDisabledState | ||||||||
setDisabledState(isDisabled: boolean)
|
||||||||
|
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
|
| validate |
validate()
|
|
Valida o controle.
Returns :
ValidationErrors | null
Erros de validação ou null. |
| writeValue | ||||||||
writeValue(value: OptionMulti[] | null)
|
||||||||
|
Escreve o valor no controle.
Parameters :
Returns :
void
|
| Private cdr |
Default value : inject(ChangeDetectorRef)
|
|
Referência ao ChangeDetectorRef. |
| control |
Default value : new FormControl<OptionMulti[]>([])
|
|
FormControl interno. |
| Private destroy$ |
Default value : new Subject<void>()
|
|
Subject para cleanup. |
| 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 onChange |
Type : function
|
Default value : () => {...}
|
|
Callback de mudança de valor. |
| Private onTouched |
Type : function
|
Default value : () => {...}
|
|
Callback de touched. |
| Private onValidationChange |
Type : function
|
Default value : () => {...}
|
|
Callback de mudança de validação. |
| 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. |
| 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, AsyncPipe } from '@angular/common';
import {
ControlValueAccessor,
FormControl,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ReactiveFormsModule,
ValidationErrors,
Validator,
} 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';
/**
* 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,
AsyncPipe,
ReactiveFormsModule,
ScrollingModule,
SqLoaderComponent,
SqTooltipComponent,
SqSelectorComponent,
SqClickOutsideDirective,
SqDataTestDirective,
],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: SqSelectMultiFormControlComponent,
multi: true,
},
{
provide: NG_VALIDATORS,
useExisting: SqSelectMultiFormControlComponent,
multi: true,
},
],
})
export class SqSelectMultiFormControlComponent implements ControlValueAccessor, Validator, OnInit, OnChanges, OnDestroy {
// ============================================================
// Inputs - Identificação
// ============================================================
/**
* ID do elemento.
*/
@Input() id = `select-multi-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
/**
* Nome do campo.
*/
@Input() name = '';
// ============================================================
// 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';
/**
* Label do campo.
*/
@Input() label = '';
/**
* Placeholder quando nenhum item selecionado.
*/
@Input() placeholder = '';
/**
* Placeholder do campo de busca.
*/
@Input() searchPlaceholder = 'Buscar...';
/**
* Classe CSS customizada.
*/
@Input() customClass = '';
/**
* 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
// ============================================================
/**
* Desabilita o componente.
*/
@Input() disabled = false;
/**
* Modo somente leitura.
*/
@Input() readonly = false;
/**
* Mostra itens selecionados dentro do input.
*/
@Input() showInside = true;
/**
* Oculta o campo de busca.
*/
@Input() hideSearch = false;
/**
* Número máximo de seleções.
*/
@Input() maxSelections?: number;
/**
* Número mínimo de seleções.
*/
@Input() minSelections?: 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;
// ============================================================
// Inputs - Tooltip
// ============================================================
/**
* Mensagem do tooltip.
*/
@Input() tooltipMessage = '';
/**
* Posição do tooltip.
*/
@Input() tooltipPlacement: 'center top' | 'center bottom' | 'left center' | 'right center' = 'right center';
/**
* Cor do tooltip.
*/
@Input() tooltipColor = 'inherit';
/**
* Ícone do tooltip.
*/
@Input() tooltipIcon = '';
// ============================================================
// 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>();
/**
* Evento de foco.
*/
@Output() focused = new EventEmitter<FocusEvent>();
/**
* Evento de blur.
*/
@Output() blurred = new EventEmitter<FocusEvent>();
// ============================================================
// 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
// ============================================================
/**
* FormControl interno.
*/
control = new FormControl<OptionMulti[]>([]);
/**
* 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>();
/**
* Subject para cleanup.
*/
private destroy$ = new Subject<void>();
/**
* Mapa de fakeIds para trackBy.
*/
private optionFakeIdMap = new WeakMap<OptionMulti, string>();
/**
* Contador para gerar fakeIds únicos.
*/
private fakeIdCounter = 0;
/**
* Callback de mudança de valor.
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function
private onChange: (value: OptionMulti[]) => void = () => {};
/**
* Callback de touched.
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function
private onTouched: () => void = () => {};
/**
* Callback de mudança de validação.
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function
private onValidationChange: () => void = () => {};
/**
* Referência ao ChangeDetectorRef.
*/
private cdr = inject(ChangeDetectorRef);
/**
* Referência ao ElementRef.
*/
private elementRef = inject(ElementRef);
/**
* Construtor do componente.
*/
constructor() {
// Subscription para controle interno
this.control.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(value => {
this.onChange(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.
*/
ngOnInit(): void {
// 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.
*/
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
// ============================================================
// ControlValueAccessor
// ============================================================
/**
* Escreve o valor no controle.
*
* @param value - Valor a ser escrito.
*/
writeValue(value: OptionMulti[] | null): void {
this.control.setValue(value || [], { emitEvent: false });
this.checkMaxSelections();
this.cdr.markForCheck();
}
/**
* Registra callback de mudança de valor.
*
* @param fn - Callback.
*/
registerOnChange(fn: (value: OptionMulti[]) => void): void {
this.onChange = fn;
}
/**
* Registra callback de touched.
*
* @param fn - Callback.
*/
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
/**
* Define estado disabled.
*
* @param isDisabled - Se está desabilitado.
*/
setDisabledState(isDisabled: boolean): void {
if (isDisabled) {
this.control.disable({ emitEvent: false });
} else {
this.control.enable({ emitEvent: false });
}
this.cdr.markForCheck();
}
// ============================================================
// Validator
// ============================================================
/**
* Valida o controle.
*
* @returns Erros de validação ou null.
*/
validate(): ValidationErrors | null {
const value = this.control.value || [];
if (this.minSelections && value.length < this.minSelections) {
return { minSelections: { required: this.minSelections, actual: value.length } };
}
if (this.maxSelections && value.length > this.maxSelections) {
return { maxSelections: { allowed: this.maxSelections, actual: value.length } };
}
return null;
}
/**
* Registra callback para mudança de validação.
*
* @param fn - Callback.
*/
registerOnValidatorChange(fn: () => void): void {
this.onValidationChange = fn;
}
// ============================================================
// Getters
// ============================================================
/**
* Retorna o valor atual do controle.
*/
get value(): OptionMulti[] {
return this.control.value || [];
}
/**
* 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);
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);
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.
*/
onBlur(event: FocusEvent): void {
const relatedTarget = event.relatedTarget as HTMLElement;
if (relatedTarget && this.elementRef.nativeElement.contains(relatedTarget)) {
return;
}
this.onTouched();
this.blurred.emit(event);
}
/**
* Handler de focus.
*
* @param event - Evento de focus.
*/
onFocus(event: FocusEvent): void {
this.focused.emit(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="sq-select-multi-form-control {{ customClass }}"
[ngStyle]="{
width: width,
'min-width': minWidth,
}"
(clickOutside)="closeDropdown()"
[clickOutsideEnabled]="isOpen"
>
<!-- Label -->
@if (label?.length || labelTemplate || tooltipMessage) {
<div
class="display-flex align-items-center wrapper-label-input"
[ngClass]="{ disabled: disabled }"
[ngStyle]="{ color: labelColor }"
>
@if (label?.length && !labelTemplate) {
<label class="label-input mr-1" [for]="id">
{{ label }}
</label>
}
@if (labelTemplate) {
<ng-container *ngTemplateOutlet="labelTemplate"></ng-container>
}
@if (tooltipMessage) {
<sq-tooltip
[message]="tooltipMessage"
[placement]="tooltipPlacement"
[color]="tooltipColor"
[icon]="tooltipIcon"
></sq-tooltip>
}
</div>
}
<!-- Container principal -->
<div
class="select-container"
[ngClass]="{
open: isOpen,
disabled: disabled,
readonly: readonly,
'no-label': !label?.length && !labelTemplate,
'mode-tags': displayMode === 'tags',
}"
[ngStyle]="{
'background-color': backgroundColor,
'border-color': borderColor,
}"
[dataTest]="selectHandleDataTest"
(click)="toggleDropdown()"
(blur)="onBlur($event)"
(focus)="onFocus($event)"
tabindex="0"
>
<!-- Loading -->
@if (loading && !isOpen) {
<div class="loading-wrapper">
<sq-loader></sq-loader>
</div>
}
<!-- Conteúdo baseado no displayMode -->
@if (!loading || isOpen) {
<!-- Modo Default: exibição compacta -->
@if (displayMode === 'default') {
@if (!value.length) {
<span class="placeholder">{{ placeholder }}</span>
} @else if (showInside) {
@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') {
@if (!value.length) {
<span class="placeholder">{{ placeholder }}</span>
} @else if (showInside) {
<div class="tags-container" [ngStyle]="{ 'max-height': tagsMaxHeight }">
@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 && !disabled) {
<i class="fas fa-times tag-remove"></i>
}
</span>
}
}
</div>
}
}
<!-- Badge de contagem (modo tags) -->
@if (displayMode === 'tags' && value.length) {
<span class="badge">{{ value.length }}</span>
}
<!-- Ícone chevron -->
<i class="icon-chevron fas fa-chevron-down" [ngClass]="{ 'rotate-180': isOpen }"></i>
}
</div>
<!-- Dropdown -->
@if (renderDropdown && !disabled && !readonly && !isMaxSelections) {
<div
class="select-dropdown scrollbar"
[ngClass]="{ open: isOpen }"
[ngStyle]="{ width: dropdownWidth }"
(scroll)="onScroll($event)"
>
<!-- Campo de busca -->
@if (!hideSearch) {
<div class="search-container">
<input
type="text"
class="search-input"
[placeholder]="searchPlaceholder"
[value]="searchText"
(input)="onSearchInput($event)"
[dataTest]="selectInputDataTest"
/>
<i class="search-icon fas fa-search"></i>
</div>
}
<!-- Virtual scroll para listas grandes ou infinity scroll -->
@if (infiniteScroll || displayOptions.length > 15) {
<cdk-virtual-scroll-viewport
[itemSize]="32"
class="virtual-scroll-viewport"
(scrolledIndexChange)="onVirtualScroll()"
>
<div
*cdkVirtualFor="let option of displayOptions; trackBy: trackByValue"
class="option-wrapper"
>
<!-- Opção com filhos -->
@if (option.children?.length) {
<div class="option-parent">
<div class="option-header" (click)="toggleCollapse(option); $event.stopPropagation()">
<i
class="fas fa-chevron-right collapse-icon"
[ngClass]="{ 'rotate-90': option.open }"
></i>
<sq-selector
[id]="id + '-vs-parent-' + option.value"
[checked]="isSelected(option)"
[indeterminate]="hasSelectedChildren(option) && !isSelected(option)"
[disabled]="option.disabled"
(click)="$event.stopPropagation()"
(valueChange)="toggleOption(option, $event)"
></sq-selector>
<span class="option-label" (click)="toggleOption(option); $event.stopPropagation()">{{ option.label }}</span>
</div>
@if (option.open) {
<div class="option-children">
@for (child of option.children; track trackByValue($index, child)) {
<div
class="option child-option"
[ngClass]="{ disabled: child.disabled, selected: isSelected(child) }"
[dataTest]="optionDataTest + '-' + child.value"
(click)="toggleOption(child); $event.stopPropagation()"
>
<sq-selector
[id]="id + '-vs-child-' + child.value"
[checked]="isSelected(child)"
[disabled]="child.disabled"
(click)="$event.stopPropagation()"
(valueChange)="toggleOption(child, $event)"
></sq-selector>
@if (optionTemplate) {
<ng-container
*ngTemplateOutlet="optionTemplate; context: { $implicit: child, option: child }"
></ng-container>
} @else {
<span class="option-label">{{ child.label }}</span>
}
</div>
}
</div>
}
</div>
} @else {
<!-- Opção simples -->
<div
class="option"
[ngClass]="{ disabled: option.disabled, selected: isSelected(option) }"
[dataTest]="optionDataTest + '-' + option.value"
(click)="toggleOption(option); $event.stopPropagation()"
>
<sq-selector
[id]="id + '-vs-' + option.value"
[checked]="isSelected(option)"
[disabled]="option.disabled"
(click)="$event.stopPropagation()"
(valueChange)="toggleOption(option, $event)"
></sq-selector>
@if (optionTemplate) {
<ng-container
*ngTemplateOutlet="optionTemplate; context: { $implicit: option, option: option }"
></ng-container>
} @else {
<span class="option-label">{{ option.label }}</span>
}
</div>
}
</div>
<!-- Loading more -->
@if (loading && hasMore) {
<div class="loading-more">
<sq-loader></sq-loader>
</div>
}
</cdk-virtual-scroll-viewport>
} @else {
<!-- Lista simples para poucas opções -->
<div class="options-list">
@for (option of displayOptions; track trackByValue($index, option)) {
<!-- Opção com filhos -->
@if (option.children?.length) {
<div class="option-parent">
<div class="option-header" (click)="toggleCollapse(option); $event.stopPropagation()">
<i
class="fas fa-chevron-right collapse-icon"
[ngClass]="{ 'rotate-90': option.open }"
></i>
<sq-selector
[id]="id + '-parent-' + option.value"
[checked]="isSelected(option)"
[indeterminate]="hasSelectedChildren(option) && !isSelected(option)"
[disabled]="option.disabled"
(click)="$event.stopPropagation()"
(valueChange)="toggleOption(option, $event)"
></sq-selector>
<span class="option-label" (click)="toggleOption(option); $event.stopPropagation()">{{ option.label }}</span>
</div>
@if (option.open) {
<div class="option-children">
@for (child of option.children; track trackByValue($index, child)) {
<div
class="option child-option"
[ngClass]="{ disabled: child.disabled, selected: isSelected(child) }"
[dataTest]="optionDataTest + '-' + child.value"
(click)="toggleOption(child); $event.stopPropagation()"
>
<sq-selector
[id]="id + '-child-' + child.value"
[checked]="isSelected(child)"
[disabled]="child.disabled"
(click)="$event.stopPropagation()"
(valueChange)="toggleOption(child, $event)"
></sq-selector>
@if (optionTemplate) {
<ng-container
*ngTemplateOutlet="optionTemplate; context: { $implicit: child, option: child }"
></ng-container>
} @else {
<span class="option-label">{{ child.label }}</span>
}
</div>
}
</div>
}
</div>
} @else {
<!-- Opção simples -->
<div
class="option"
[ngClass]="{ disabled: option.disabled, selected: isSelected(option) }"
[dataTest]="optionDataTest + '-' + option.value"
(click)="toggleOption(option); $event.stopPropagation()"
>
<sq-selector
[id]="id + '-' + option.value"
[checked]="isSelected(option)"
[disabled]="option.disabled"
(click)="$event.stopPropagation()"
(valueChange)="toggleOption(option, $event)"
></sq-selector>
@if (optionTemplate) {
<ng-container
*ngTemplateOutlet="optionTemplate; context: { $implicit: option, option: option }"
></ng-container>
} @else {
<span class="option-label">{{ option.label }}</span>
}
</div>
}
}
</div>
}
<!-- Empty state -->
@if (displayOptions.length === 0 && !loading) {
<div class="empty-state">
@if (emptyTemplate) {
<ng-container *ngTemplateOutlet="emptyTemplate"></ng-container>
} @else {
<span>Nenhuma opção encontrada</span>
}
</div>
}
</div>
}
</div>
./sq-select-multi-form-control.component.scss
// Estilos para sq-select-multi-form-control
.sq-select-multi-form-control {
position: relative;
display: block;
}
// Label
.wrapper-label-input {
margin-bottom: 0.25rem;
&.disabled {
opacity: 0.6;
}
.label-input {
font-size: 0.875rem;
font-weight: 500;
color: var(--text_color, #333);
}
}
// Container principal
.select-container {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 44px;
padding: 0.5rem 2.5rem 0.5rem 1rem;
border: 1px solid var(--border_color, #ccc);
border-radius: 5px;
background-color: var(--background, #fff);
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
box-sizing: border-box;
&:hover:not(.disabled):not(.readonly) {
border-color: var(--primary_color, #007bff);
}
&:focus:not(.disabled):not(.readonly) {
outline: none;
border-color: var(--primary_color, #007bff);
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.15);
}
&.open {
border-color: var(--primary_color, #007bff);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
&.disabled {
opacity: 0.6;
cursor: not-allowed;
background-color: var(--color_bg_input_disabled, #f5f5f5);
}
&.readonly {
cursor: default;
background-color: var(--color_bg_input_readonly, #fafafa);
}
// Modo tags: permite wrap mas mantém altura mínima
&.mode-tags {
flex-wrap: wrap;
min-height: 44px;
padding: 0.35rem 2.5rem 0.35rem 0.5rem;
align-content: center;
}
}
// Placeholder
.placeholder {
color: var(--color_text-icon_neutral_tertiary, #999);
font-size: 0.875rem;
}
// Resumo selecionado (modo default)
.selected-summary {
font-size: 0.875rem;
color: var(--text_color, #333);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// Container de tags (modo tags)
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
overflow-y: auto;
flex: 1;
}
// Tag individual
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.5rem;
background-color: var(--primary_color, #007bff);
color: var(--white-html, #fff);
border-radius: 3px;
font-size: 0.75rem;
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background-color: var(--primary_color_hover, #0056b3);
}
.tag-remove {
font-size: 0.65rem;
opacity: 0.8;
&:hover {
opacity: 1;
}
}
}
// Badge de contagem
.badge {
display: flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 0.35rem;
background-color: var(--primary_color, #007bff);
color: var(--white-html, #fff);
border-radius: 10px;
font-size: 0.7rem;
font-weight: 600;
margin-left: auto;
}
// Ícone chevron
.icon-chevron {
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
font-size: 0.75rem;
color: var(--color_text-icon_neutral_tertiary, #666);
transition: transform 0.2s ease;
&.rotate-180 {
transform: translateY(-50%) rotate(180deg);
}
}
// Loading
.loading-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
// Dropdown
.select-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
max-height: 0;
overflow: hidden;
background: var(--background, #fff);
border: 1px solid var(--border_color, #aaa);
border-top: none;
border-radius: 0 0 5px 5px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
transition: max-height 0.2s ease;
&.open {
max-height: 350px;
overflow-y: auto;
}
}
// Campo de busca
.search-container {
position: sticky;
top: 0;
z-index: 1;
padding: 0.5rem;
background-color: var(--background, #fff);
border-bottom: 1px solid var(--color_border_input, #eee);
.search-input {
width: 100%;
min-height: 32px;
padding: 0.4rem 2rem 0.4rem 0.5rem;
border: 1px solid var(--color_border_input, #ddd);
border-radius: 4px;
font-size: 0.875rem;
outline: none;
background: var(--color_bg_input, #fff);
&:focus {
border-color: var(--primary_color, #007bff);
}
&::placeholder {
color: var(--color_text-icon_neutral_tertiary, #999);
}
}
.search-icon {
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
color: var(--color_text-icon_neutral_tertiary, #999);
font-size: 0.75rem;
}
}
// Virtual scroll viewport
.virtual-scroll-viewport {
height: 250px;
width: 100%;
}
// Lista de opções
.options-list {
max-height: 250px;
overflow-y: auto;
}
// Opção
.option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
cursor: pointer;
transition: background-color 0.1s ease;
&:hover:not(.disabled) {
background-color: var(--color_bg_box_neutral_secondary, #f5f5f5);
}
&.selected {
background-color: var(--color_bg_box_brand_primary_light, rgba(0, 123, 255, 0.1));
}
&.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.option-label {
font-size: 0.875rem;
color: var(--text_color, #333);
}
}
// Opção com filhos (hierarquia)
.option-parent {
.option-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
cursor: pointer;
transition: background-color 0.1s ease;
&:hover {
background-color: var(--color_bg_box_neutral_secondary, #f5f5f5);
}
.collapse-icon {
font-size: 0.65rem;
color: var(--color_text-icon_neutral_tertiary, #666);
transition: transform 0.2s ease;
&.rotate-90 {
transform: rotate(90deg);
}
}
}
.option-children {
padding-left: 1.5rem;
border-left: 2px solid var(--color_border_input, #eee);
margin-left: 0.75rem;
}
.child-option {
padding: 0.35rem 0.5rem;
font-size: 0.8125rem;
}
}
// Empty state
.empty-state {
padding: 1rem;
text-align: center;
color: var(--color_text-icon_neutral_tertiary, #999);
font-size: 0.875rem;
}
// Loading more
.loading-more {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
}