File

src/components/sq-select-multi-form-control/sq-select-multi-form-control.component.ts

Description

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>

Implements

ControlValueAccessor Validator OnInit OnChanges OnDestroy

Metadata

Index

Properties
Methods
Inputs
Outputs
Accessors

Constructor

constructor()

Construtor do componente.

Inputs

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.

  • 'default': Compacto, mostra "Item 1 +N mais"
  • 'tags': Tags individuais removíveis com X
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.

Outputs

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).

Methods

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 :
Name Type Optional Description
term string No
  • Termo de busca.
Returns : OptionMulti[]

Opções filtradas.

Private getOptionFakeId
getOptionFakeId(option: OptionMulti)

Obtém ou gera um fakeId para a opção (para trackBy).

Parameters :
Name Type Optional Description
option OptionMulti No
  • Opção.
Returns : string

FakeId da opção.

hasSelectedChildren
hasSelectedChildren(option: OptionMulti)

Verifica se algum filho está selecionado.

Parameters :
Name Type Optional
option OptionMulti No
Returns : boolean
isSelected
isSelected(option: OptionMulti)

Verifica se uma opção está selecionada.

Parameters :
Name Type Optional Description
option OptionMulti No
  • A opção a verificar.
Returns : boolean

true se a opção está selecionada.

ngOnChanges
ngOnChanges(changes: SimpleChanges)

Detecta mudanças nos inputs.

Parameters :
Name Type Optional Description
changes SimpleChanges No
  • Mudanças detectadas.
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 :
Name Type Optional Description
event FocusEvent No
  • Evento de blur.
Returns : void
onFocus
onFocus(event: FocusEvent)

Handler de focus.

Parameters :
Name Type Optional Description
event FocusEvent No
  • Evento de focus.
Returns : void
onScroll
onScroll(event: Event)

Handler de scroll para infinity scroll (lista simples).

Parameters :
Name Type Optional Description
event Event No
  • Evento de scroll.
Returns : void
onSearchInput
onSearchInput(event: Event)

Handler para input de busca.

Parameters :
Name Type Optional Description
event Event No
  • Evento de input.
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 :
Name Type Optional Description
fn function No
  • Callback.
Returns : void
registerOnTouched
registerOnTouched(fn: () => void)

Registra callback de touched.

Parameters :
Name Type Optional Description
fn function No
  • Callback.
Returns : void
registerOnValidatorChange
registerOnValidatorChange(fn: () => void)

Registra callback para mudança de validação.

Parameters :
Name Type Optional Description
fn function No
  • Callback.
Returns : void
removeItem
removeItem(option: OptionMulti, event?: Event)

Remove uma tag (modo 'tags').

Parameters :
Name Type Optional
option OptionMulti No
event Event Yes
Returns : void
setDisabledState
setDisabledState(isDisabled: boolean)

Define estado disabled.

Parameters :
Name Type Optional Description
isDisabled boolean No
  • Se está desabilitado.
Returns : void
toggleCollapse
toggleCollapse(option: OptionMulti)

Toggle para expandir/colapsar hierarquia.

Parameters :
Name Type Optional
option OptionMulti No
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 :
Name Type Optional Description
option OptionMulti No
  • A opção a ser alternada.
event literal type | boolean Yes
  • Evento opcional com estado checked.
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 :
Name Type Optional Description
value OptionMulti[] | null No
  • Valor a ser escrito.
Returns : void

Properties

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 :
Name Description
index
  • Índice do item.
option
  • Opção.
valueChanged
Default value : false

Valor mudou desde abertura.

Accessors

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;
}

Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""