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>

Extends

SqFormControlBaseDirective

Implements

OnInit OnDestroy OnChanges

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.

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.

fullOptionAsValue
Type : boolean
Default value : false

Se true, retorna o array de objetos OptionMulti completos como value. Se false (padrão), retorna apenas um array com os values das Options selecionadas.

hasMore
Type : boolean
Default value : false

Indica se há mais itens para carregar (infinity scroll).

hideSearch
Type : boolean
Default value : false

Oculta o campo de busca.

infiniteScroll
Type : boolean
Default value : false

Habilita infinity scroll.

labelColor
Type : string
Default value : ''

Cor da label.

loading
Type : boolean
Default value : false

Indica carregamento.

maxSelections
Type : number

Número máximo de seleções (apenas para controle visual, não valida).

minCharactersToSearch
Type : number
Default value : 0

Caracteres mínimos para busca.

minWidth
Type : string
Default value : '200px'

Largura mínima.

optionDataTest
Type : string
Default value : 'option-select-multi'

Data test para as opções.

options
Type : OptionMulti[]
Default value : []

Opções disponíveis.

searchable
Type : "local" | "remote"

Modo de busca: 'local' filtra client-side, 'remote' emite evento.

searchDebounce
Type : number
Default value : 300

Debounce da busca em ms.

searchPlaceholder
Type : string
Default value : 'Buscar...'

Placeholder do campo de busca.

selectHandleDataTest
Type : string
Default value : 'select-multi'

Data test para o handle do select.

selectInputDataTest
Type : string
Default value : 'input-select-multi'

Data test para o input de busca.

showInside
Type : boolean
Default value : true

Mostra itens selecionados dentro do input.

tagsMaxHeight
Type : string
Default value : '100px'

Altura máxima das tags (modo 'tags').

trackByFn
Type : function

Função customizada para trackBy.

width
Type : string
Default value : '100%'

Largura do componente.

customClass
Type : string
Default value : ''

Custom CSS class for the input element.

id
Type : string

The id attribute for the input element.

label
Type : string

An optional label for the input.

name
Type : string
Default value : `random-name-${(1 + Date.now() + Math.random()).toString().replace('.', '')}`

The name attribute for the input element.

placeholder
Type : string
Default value : ''

Placeholder text for the input element.

readonly
Type : boolean
Default value : false

Flag to make the input element readonly.

tooltipColor
Type : string
Default value : 'inherit'

Color of the tooltip.

tooltipIcon
Type : string
Default value : ''

Icon for the tooltip.

tooltipMessage
Type : string
Default value : ''

Tooltip message to display.

tooltipPlacement
Type : "center top" | "center bottom" | "left center" | "right center"
Default value : 'right center'

Placement of the tooltip.

Outputs

closeChange
Type : EventEmitter

Evento quando dropdown fecha.

loadMore
Type : EventEmitter

Evento para carregar mais itens.

removeTag
Type : EventEmitter

Evento quando uma tag é removida (modo 'tags').

searchChange
Type : EventEmitter

Evento de busca (modo remote).

blurred
Type : EventEmitter<FocusEvent>

Event emitter for blur events.

focused
Type : EventEmitter<FocusEvent>

Event emitter for focus events.

valueChange
Type : EventEmitter<any>

Event emitter for input value changes.

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: any) => void)

Registra callback de mudança de valor.

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
writeValue
writeValue(value: OptionMulti[] | any[] | null)

Escreve o valor no controle.

Parameters :
Name Type Optional Description
value OptionMulti[] | any[] | null No
  • Valor a ser escrito (pode ser array de OptionMulti ou array de values).
Returns : void
registerOnTouched
registerOnTouched(fn: any)

ControlValueAccessor: Registers a callback function that is called when the control is touched.

Parameters :
Name Type Optional Description
fn any No
  • The callback function.
Returns : void
Protected watchValueChanges
watchValueChanges()

Watch for value changes and emit the value to the parent component. Subscribes to control.valueChanges with distinctUntilChanged to avoid duplicate emissions. Automatically unsubscribes when the component is destroyed.

Returns : void

Properties

Private cdr
Default value : inject(ChangeDetectorRef)

Referência ao ChangeDetectorRef.

Private elementRef
Default value : inject(ElementRef)

Referência ao ElementRef.

emptyTemplate
Type : TemplateRef<HTMLElement> | null
Default value : null
Decorators :
@ContentChild('emptyTemplate')

Template customizado para estado vazio.

Private fakeIdCounter
Type : number
Default value : 0

Contador para gerar fakeIds únicos.

filteredOptions
Type : OptionMulti[]
Default value : []

Opções filtradas.

isMaxSelections
Default value : false

Atingiu máximo de seleções.

isOpen
Default value : false

Dropdown aberto.

labelTemplate
Type : TemplateRef<HTMLElement> | null
Default value : null
Decorators :
@ContentChild('labelTemplate')

Template customizado para o label.

Private optionFakeIdMap
Default value : new WeakMap<OptionMulti, string>()

Mapa de fakeIds para trackBy.

optionTemplate
Type : TemplateRef<> | null
Default value : null
Decorators :
@ContentChild('optionTemplate')

Template customizado para cada opção no dropdown.

renderDropdown
Default value : false

Renderiza dropdown.

Private searchSubject
Default value : new Subject<string>()

Subject para busca.

searchText
Type : string
Default value : ''

Texto de busca.

selectedTemplate
Type : TemplateRef<> | null
Default value : null
Decorators :
@ContentChild('selectedTemplate')

Template customizado para exibição dos itens selecionados (modo default).

tagTemplate
Type : TemplateRef<> | null
Default value : null
Decorators :
@ContentChild('tagTemplate')

Template customizado para cada tag (modo tags).

trackByValue
Default value : () => {...}

Função trackBy para ngFor.

Parameters :
Name Description
index
  • Índice do item.
option
  • Opção.
valueChanged
Default value : false

Valor mudou desde abertura.

control
Default value : new FormControl<any>(null)

Internal FormControl for managing the input value and state.

Protected destroy$
Default value : new Subject<void>()

Subject for managing subscriptions.

Protected onChange
Type : function
Default value : () => {...}

ControlValueAccessor callback function called when the value changes. Registered via registerOnChange().

Protected onTouched
Type : function
Default value : () => {...}

ControlValueAccessor callback function called when the control is touched. Registered via registerOnTouched().

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 } from '@angular/common';
import { NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { Subject, debounceTime, distinctUntilChanged, takeUntil } from 'rxjs';
import { OptionMulti } from '../../interfaces/option.interface';
import { SqLoaderComponent } from '../sq-loader/sq-loader.component';
import { SqTooltipComponent } from '../sq-tooltip/sq-tooltip.component';
import { SqSelectorComponent } from '../sq-selector/sq-selector.component';
import { SqClickOutsideDirective } from '../../directives/sq-click-outside/sq-click-outside.directive';
import { SqDataTestDirective } from '../../directives/sq-data-test/sq-data-test.directive';
import { UniversalSafePipe } from '../../pipes/universal-safe/universal-safe.pipe';
import { SqFormControlBaseDirective } from '../../directives/sq-form-control-base';

/**
 * Componente de select múltiplo com Reactive Forms.
 * Unifica sq-select-multi e sq-select-multi-tags em um único componente.
 *
 * @example
 * // Modo default (compacto)
 * <sq-select-multi-form-control
 *   [label]="'Cidades'"
 *   [options]="cities"
 *   [formControl]="citiesControl"
 * ></sq-select-multi-form-control>
 *
 * @example
 * // Modo tags (tags removíveis)
 * <sq-select-multi-form-control
 *   [label]="'Tags'"
 *   [options]="tags"
 *   [formControl]="tagsControl"
 *   [displayMode]="'tags'"
 * ></sq-select-multi-form-control>
 */
@Component({
  selector: 'sq-select-multi-form-control',
  templateUrl: './sq-select-multi-form-control.component.html',
  styleUrls: ['./sq-select-multi-form-control.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true,
  imports: [
    NgClass,
    NgStyle,
    NgTemplateOutlet,
    ReactiveFormsModule,
    ScrollingModule,
    SqLoaderComponent,
    SqTooltipComponent,
    SqSelectorComponent,
    SqClickOutsideDirective,
    SqDataTestDirective,
    UniversalSafePipe,
  ],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: SqSelectMultiFormControlComponent,
      multi: true,
    },
  ],
})
export class SqSelectMultiFormControlComponent
  extends SqFormControlBaseDirective
  implements OnInit, OnDestroy, OnChanges
{
  // ============================================================
  // Inputs - Aparência
  // ============================================================

  /**
   * Modo de exibição dos itens selecionados.
   * - 'default': Compacto, mostra "Item 1 +N mais"
   * - 'tags': Tags individuais removíveis com X
   */
  @Input() displayMode: 'default' | 'tags' = 'default';

  /**
   * Placeholder do campo de busca.
   */
  @Input() searchPlaceholder = 'Buscar...';

  /**
   * Cor de fundo.
   */
  @Input() backgroundColor = '';

  /**
   * Cor da borda.
   */
  @Input() borderColor = '';

  /**
   * Cor da label.
   */
  @Input() labelColor = '';

  /**
   * Largura do componente.
   */
  @Input() width = '100%';

  /**
   * Largura mínima.
   */
  @Input() minWidth = '200px';

  /**
   * Largura do dropdown.
   */
  @Input() dropdownWidth = '100%';

  /**
   * Altura máxima das tags (modo 'tags').
   */
  @Input() tagsMaxHeight = '100px';

  // ============================================================
  // Inputs - Comportamento
  // ============================================================

  /**
   * Mostra itens selecionados dentro do input.
   */
  @Input() showInside = true;

  /**
   * Oculta o campo de busca.
   */
  @Input() hideSearch = false;

  /**
   * Número máximo de seleções (apenas para controle visual, não valida).
   */
  @Input() maxSelections?: number;

  /**
   * Caracteres mínimos para busca.
   */
  @Input() minCharactersToSearch = 0;

  /**
   * Debounce da busca em ms.
   */
  @Input() searchDebounce = 300;

  /**
   * Opções disponíveis.
   */
  @Input() options: OptionMulti[] = [];

  /**
   * Indica carregamento.
   */
  @Input() loading = false;

  /**
   * Modo de busca: 'local' filtra client-side, 'remote' emite evento.
   */
  @Input() searchable?: 'local' | 'remote';

  /**
   * Indica se há mais itens para carregar (infinity scroll).
   */
  @Input() hasMore = false;

  /**
   * Habilita infinity scroll.
   */
  @Input() infiniteScroll = false;

  /**
   * Função customizada para trackBy.
   */
  @Input() trackByFn?: (index: number, option: OptionMulti) => unknown;

  /**
   * Se true, retorna o array de objetos OptionMulti completos como value.
   * Se false (padrão), retorna apenas um array com os values das Options selecionadas.
   */
  @Input() fullOptionAsValue = false;

  // ============================================================
  // Inputs - Data Test
  // ============================================================

  /**
   * Data test para o handle do select.
   */
  @Input() selectHandleDataTest = 'select-multi';

  /**
   * Data test para o input de busca.
   */
  @Input() selectInputDataTest = 'input-select-multi';

  /**
   * Data test para as opções.
   */
  @Input() optionDataTest = 'option-select-multi';

  // ============================================================
  // Outputs
  // ============================================================

  /**
   * Evento de busca (modo remote).
   */
  @Output() searchChange = new EventEmitter<string>();

  /**
   * Evento para carregar mais itens.
   */
  @Output() loadMore = new EventEmitter<void>();

  /**
   * Evento quando dropdown fecha.
   */
  @Output() closeChange = new EventEmitter<boolean>();

  /**
   * Evento quando uma tag é removida (modo 'tags').
   */
  @Output() removeTag = new EventEmitter<OptionMulti>();

  // ============================================================
  // Templates
  // ============================================================

  /**
   * Template customizado para o label.
   */
  @ContentChild('labelTemplate') labelTemplate: TemplateRef<HTMLElement> | null = null;

  /**
   * Template customizado para cada opção no dropdown.
   */
  @ContentChild('optionTemplate') optionTemplate: TemplateRef<unknown> | null = null;

  /**
   * Template customizado para exibição dos itens selecionados (modo default).
   */
  @ContentChild('selectedTemplate') selectedTemplate: TemplateRef<unknown> | null = null;

  /**
   * Template customizado para estado vazio.
   */
  @ContentChild('emptyTemplate') emptyTemplate: TemplateRef<HTMLElement> | null = null;

  /**
   * Template customizado para cada tag (modo tags).
   */
  @ContentChild('tagTemplate') tagTemplate: TemplateRef<unknown> | null = null;

  // ============================================================
  // Estado interno
  // ============================================================

  /**
   * Dropdown aberto.
   */
  isOpen = false;

  /**
   * Renderiza dropdown.
   */
  renderDropdown = false;

  /**
   * Texto de busca.
   */
  searchText = '';

  /**
   * Opções filtradas.
   */
  filteredOptions: OptionMulti[] = [];

  /**
   * Valor mudou desde abertura.
   */
  valueChanged = false;

  /**
   * Atingiu máximo de seleções.
   */
  isMaxSelections = false;

  /**
   * Subject para busca.
   */
  private searchSubject = new Subject<string>();

  /**
   * Mapa de fakeIds para trackBy.
   */
  private optionFakeIdMap = new WeakMap<OptionMulti, string>();

  /**
   * Contador para gerar fakeIds únicos.
   */
  private fakeIdCounter = 0;

  /**
   * Referência ao ChangeDetectorRef.
   */
  private cdr = inject(ChangeDetectorRef);

  /**
   * Referência ao ElementRef.
   */
  private elementRef = inject(ElementRef);

  /**
   * Construtor do componente.
   */
  constructor() {
    super();
    // Inicializa o control com array vazio em vez de string vazia
    this.control.setValue([] as any, { emitEvent: false });
    // Subscription para controle interno
    this.control.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((value: any) => {
      const options = (value as OptionMulti[]) || [];
      // Se fullOptionAsValue for true, retorna o array de objetos completos
      // Caso contrário, retorna apenas um array com os values das Options
      if (this.fullOptionAsValue) {
        this.onChange(options);
      } else {
        this.onChange(options.map(opt => opt.value));
      }
      this.checkMaxSelections();
    });

    // Subscription para busca com debounce
    this.searchSubject
      .pipe(debounceTime(this.searchDebounce), distinctUntilChanged(), takeUntil(this.destroy$))
      .subscribe(term => {
        if (this.searchable === 'remote') {
          this.searchChange.emit(term);
        }
        this.cdr.markForCheck();
      });
  }

  /**
   * Inicialização do componente.
   */
  override ngOnInit(): void {
    super.ngOnInit();
    // Inicializa filteredOptions com options
    this.filteredOptions = this.options;
  }

  /**
   * Detecta mudanças nos inputs.
   *
   * @param changes - Mudanças detectadas.
   */
  ngOnChanges(changes: SimpleChanges): void {
    // Atualiza filteredOptions quando options mudam (busca remota)
    if (changes['options'] && !changes['options'].firstChange) {
      this.filteredOptions = this.options;
      this.cdr.markForCheck();
    }
  }

  /**
   * Cleanup do componente.
   */
  override ngOnDestroy(): void {
    super.ngOnDestroy();
  }

  // ============================================================
  // ControlValueAccessor (sobrescreve métodos do base class)
  // ============================================================

  /**
   * Escreve o valor no controle.
   *
   * @param value - Valor a ser escrito (pode ser array de OptionMulti ou array de values).
   */
  override writeValue(value: OptionMulti[] | any[] | null): void {
    if (!value || value.length === 0) {
      this.control.setValue([] as any, { emitEvent: false });
      this.checkMaxSelections();
      this.cdr.markForCheck();
      return;
    }

    // Verifica se é um array de objetos OptionMulti (tem propriedade 'value' e 'label')
    const isOptionMultiArray =
      typeof value[0] === 'object' && value[0] !== null && 'value' in value[0] && 'label' in value[0];

    // Se fullOptionAsValue for false e o valor for um array de primitivos,
    // precisa converter para array de OptionMulti procurando nas options
    if (!this.fullOptionAsValue && !isOptionMultiArray) {
      const options = this.options.filter(opt => (value as any[]).includes(opt.value));
      this.control.setValue(options as any, { emitEvent: false });
    } else {
      // Se fullOptionAsValue for true ou já for array de objetos OptionMulti
      this.control.setValue(value as OptionMulti[] as any, { emitEvent: false });
    }

    this.checkMaxSelections();
    this.cdr.markForCheck();
  }

  /**
   * Registra callback de mudança de valor.
   *
   * @param fn - Callback.
   */
  override registerOnChange(fn: (value: any) => void): void {
    this.onChange = fn;
  }

  /**
   * Define estado disabled.
   *
   * @param isDisabled - Se está desabilitado.
   */
  override setDisabledState(isDisabled: boolean): void {
    super.setDisabledState(isDisabled);
    this.cdr.markForCheck();
  }

  // ============================================================
  // Getters
  // ============================================================

  /**
   * Retorna o valor atual do controle.
   */
  override get value(): OptionMulti[] {
    return (this.control.value as unknown as OptionMulti[]) || [];
  }

  /**
   * Retorna as opções a serem exibidas (filtradas ou não).
   */
  get displayOptions(): OptionMulti[] {
    if (this.searchable === 'local' && this.searchText) {
      return this.filterOptionsLocally(this.searchText);
    }
    return this.filteredOptions;
  }

  /**
   * Retorna o texto resumido para exibição (modo default).
   */
  get summaryText(): string {
    const values = this.value;
    if (!values.length) return '';
    if (values.length === 1) return values[0].label;
    return `${values[0].label} +${values.length - 1}`;
  }

  // ============================================================
  // Métodos públicos - Dropdown
  // ============================================================

  /**
   * Alterna o estado do dropdown (abre/fecha).
   */
  async toggleDropdown(): Promise<void> {
    if (this.disabled || this.readonly || this.isMaxSelections) return;

    if (this.isOpen) {
      this.closeDropdown();
    } else {
      await this.openDropdown();
    }
  }

  /**
   * Abre o dropdown.
   */
  async openDropdown(): Promise<void> {
    this.renderDropdown = true;
    this.filteredOptions = this.options;
    this.cdr.detectChanges();

    // Se modo remote e lista vazia, emite busca inicial (primeira página)
    if (this.searchable === 'remote' && this.options.length === 0) {
      this.searchChange.emit('');
    }

    await new Promise(resolve => setTimeout(resolve, 50));
    this.isOpen = true;
    this.cdr.detectChanges();
  }

  /**
   * Fecha o dropdown.
   */
  closeDropdown(): void {
    this.isOpen = false;
    this.searchText = '';
    this.closeChange.emit(this.valueChanged);
    this.valueChanged = false;
    this.cdr.detectChanges();

    setTimeout(() => {
      this.renderDropdown = false;
      this.cdr.detectChanges();
    }, 200);
  }

  // ============================================================
  // Métodos públicos - Seleção
  // ============================================================

  /**
   * Verifica se uma opção está selecionada.
   *
   * @param option - A opção a verificar.
   * @returns true se a opção está selecionada.
   */
  isSelected(option: OptionMulti): boolean {
    return this.value.some(v => v.value === option.value);
  }

  /**
   * Alterna a seleção de uma opção.
   *
   * @param option - A opção a ser alternada.
   * @param event - Evento opcional com estado checked.
   */
  toggleOption(option: OptionMulti, event?: { value: boolean; checked: boolean } | boolean): void {
    if (option.disabled) return;

    // Normaliza o evento - se não fornecido, inverte o estado atual
    let checked: boolean;
    if (event === undefined) {
      checked = !this.isSelected(option);
    } else {
      checked = typeof event === 'boolean' ? event : event.checked;
    }

    let newValue = [...this.value];

    if (checked) {
      if (!this.isSelected(option)) {
        newValue.push(option);
      }
      // Adiciona filhos se existirem
      if (option.children?.length) {
        option.children.forEach(child => {
          if (!newValue.some(v => v.value === child.value)) {
            newValue.push(child);
          }
        });
      }
    } else {
      newValue = newValue.filter(v => v.value !== option.value);
      // Remove filhos se existirem
      if (option.children?.length) {
        const childValues = option.children.map(c => c.value);
        newValue = newValue.filter(v => !childValues.includes(v.value));
      }
    }

    this.control.setValue(newValue as any);
    this.valueChanged = true;
    this.cdr.markForCheck();
  }

  /**
   * Remove uma tag (modo 'tags').
   */
  removeItem(option: OptionMulti, event?: Event): void {
    event?.stopPropagation();
    if (this.readonly || this.disabled) return;

    let newValue = this.value.filter(v => v.value !== option.value);

    // Remove filhos também
    if (option.children?.length) {
      const childValues = option.children.map(c => c.value);
      newValue = newValue.filter(v => !childValues.includes(v.value));
    }

    this.control.setValue(newValue as any);
    this.removeTag.emit(option);
  }

  /**
   * Verifica se algum filho está selecionado.
   */
  hasSelectedChildren(option: OptionMulti): boolean {
    if (!option.children?.length) return false;
    return option.children.some(child => this.isSelected(child));
  }

  /**
   * Toggle para expandir/colapsar hierarquia.
   */
  toggleCollapse(option: OptionMulti): void {
    option.open = !option.open;
  }

  // ============================================================
  // Métodos públicos - Busca
  // ============================================================

  /**
   * Handler para input de busca.
   *
   * @param event - Evento de input.
   */
  onSearchInput(event: Event): void {
    const input = event.target as HTMLInputElement;
    const term = input.value;
    this.searchText = term;

    if (this.minCharactersToSearch && term.length < this.minCharactersToSearch) {
      return;
    }

    if (this.searchable === 'local') {
      this.filteredOptions = this.filterOptionsLocally(term);
      this.cdr.markForCheck();
    } else if (this.searchable === 'remote') {
      this.searchSubject.next(term);
    }
  }

  // ============================================================
  // Métodos públicos - Scroll
  // ============================================================

  /**
   * Handler de scroll para infinity scroll (lista simples).
   *
   * @param event - Evento de scroll.
   */
  onScroll(event: Event): void {
    if (!this.infiniteScroll || !this.hasMore || this.loading) return;

    const element = event.target as HTMLElement;
    const scrollBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
    if (scrollBottom < 100) {
      this.loadMore.emit();
    }
  }

  /**
   * Handler de scroll para infinity scroll (virtual scroll).
   */
  onVirtualScroll(): void {
    if (!this.infiniteScroll || !this.hasMore || this.loading) return;

    const viewport = this.elementRef.nativeElement.querySelector('cdk-virtual-scroll-viewport');
    if (viewport) {
      const scrollBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
      if (scrollBottom < 100) {
        this.loadMore.emit();
      }
    }
  }

  /**
   * Função trackBy para ngFor.
   *
   * @param index - Índice do item.
   * @param option - Opção.
   * @returns Identificador único da opção.
   */
  trackByValue = (index: number, option: OptionMulti): unknown => {
    if (this.trackByFn) {
      return this.trackByFn(index, option);
    }
    return this.getOptionFakeId(option);
  };

  // ============================================================
  // Métodos públicos - Eventos
  // ============================================================

  /**
   * Handler de blur.
   *
   * @param event - Evento de blur.
   */
  override onBlur(event: FocusEvent): void {
    const relatedTarget = event.relatedTarget as HTMLElement;
    if (relatedTarget && this.elementRef.nativeElement.contains(relatedTarget)) {
      return;
    }
    super.onBlur(event);
  }

  /**
   * Handler de focus.
   *
   * @param event - Evento de focus.
   */
  override onFocus(event: FocusEvent): void {
    super.onFocus(event);
  }

  // ============================================================
  // Métodos privados
  // ============================================================

  /**
   * Filtra opções localmente pelo termo de busca.
   *
   * @param term - Termo de busca.
   * @returns Opções filtradas.
   */
  private filterOptionsLocally(term: string): OptionMulti[] {
    if (!term) return this.options;

    const lowerTerm = term.toLowerCase();
    return this.options.filter(opt => {
      const matchesLabel = opt.label.toLowerCase().includes(lowerTerm);
      const matchesChildren = opt.children?.some(child => child.label.toLowerCase().includes(lowerTerm));
      return matchesLabel || matchesChildren;
    });
  }

  /**
   * Verifica e atualiza estado de máximo de seleções.
   */
  private checkMaxSelections(): void {
    if (this.maxSelections) {
      this.isMaxSelections = this.value.length >= this.maxSelections;
      if (this.isMaxSelections && this.isOpen) {
        this.closeDropdown();
      }
    } else {
      this.isMaxSelections = false;
    }
  }

  /**
   * Obtém ou gera um fakeId para a opção (para trackBy).
   *
   * @param option - Opção.
   * @returns FakeId da opção.
   */
  private getOptionFakeId(option: OptionMulti): string {
    let fakeId = this.optionFakeIdMap.get(option);
    if (!fakeId) {
      fakeId = `option-${++this.fakeIdCounter}`;
      this.optionFakeIdMap.set(option, fakeId);
    }
    return fakeId;
  }
}
<div class="wrapper-all-inside-input {{ customClass }}">
  @if (label?.length || labelTemplate || tooltipMessage) {
    <label
      class="display-flex align-items-center"
      [ngClass]="{
        readonly: readonly || isMaxSelections,
      }"
      [for]="id"
    >
      @if (label && !labelTemplate) {
        <div [ngStyle]="{ color: labelColor }" [innerHtml]="label | universalSafe"></div>
      }
      @if (labelTemplate) {
        <span>
          <ng-container *ngTemplateOutlet="labelTemplate"></ng-container>
        </span>
      }
      @if (tooltipMessage) {
        <sq-tooltip
          class="ml-1"
          [message]="tooltipMessage"
          [placement]="tooltipPlacement"
          [color]="tooltipColor"
          [icon]="tooltipIcon"
        ></sq-tooltip>
      }
    </label>
  }

  <!-- Container principal -->
  <div
    class="wrapper-select-multi"
    [ngClass]="{
      error: false,
      disabled: disabled,
      readonly: readonly || isMaxSelections,
      loading: loading,
    }"
  >
    <div
      [class]="'input-fake col border-' + (borderColor || '')"
      style="min-height: auto"
      [ngStyle]="{
        'background-color': backgroundColor,
        'border-color': borderColor,
      }"
      [ngClass]="{
        'no-label': !(label?.length || labelTemplate),
        'has-icon': false,
        disabled: disabled,
        readonly: readonly || isMaxSelections,
      }"
      [dataTest]="selectHandleDataTest"
      [clickOutsideEnabled]="isOpen"
      (clickOutside)="closeDropdown()"
    >
      <div
        class="input-fake-content"
        [ngClass]="{
          disabled: disabled,
          readonly: readonly,
        }"
        [style.maxHeight]="displayMode === 'tags' ? tagsMaxHeight : 'auto'"
        (click)="toggleDropdown()"
      >
        @if (loading) {
          <div class="loading-wrapper">
            <sq-loader></sq-loader>
          </div>
        }
        @if (!value.length || !showInside) {
          <span>{{ placeholder }}</span>
        }
        @if (showInside && value.length) {
          <!-- Modo Default: exibição compacta -->
          @if (displayMode === 'default') {
            @if (selectedTemplate) {
              <ng-container
                *ngTemplateOutlet="selectedTemplate; context: { $implicit: value, selected: value, count: value.length }"
              ></ng-container>
            } @else {
              <span class="selected-summary">{{ summaryText }}</span>
            }
          }
          <!-- Modo Tags: tags individuais -->
          @if (displayMode === 'tags') {
            <div class="input-fake-content-text">
              @for (opt of value; track trackByValue($index, opt)) {
                @if (tagTemplate) {
                  <ng-container
                    *ngTemplateOutlet="tagTemplate; context: { $implicit: opt, option: opt, remove: removeItem.bind(this) }"
                  ></ng-container>
                } @else {
                  <span class="tag" (click)="removeItem(opt, $event)">
                    {{ opt.label }}
                    @if (!readonly) {
                      <i class="fas fa-times" [style.minWidth]="'auto'"></i>
                    }
                  </span>
                }
              }
            </div>
          }
        }
        @if (value.length) {
          <span class="badge">{{ value.length }}</span>
        }
        @if (!loading) {
          <i class="icon-down fas fa-chevron-down"></i>
        }
      </div>

      @if (!loading && !disabled && !readonly && !isMaxSelections && renderDropdown) {
        <div
          id="sq-select-multi-tags-scroll"
          class="input-window scrollbar"
          [ngClass]="{
            open: !loading && !disabled && !isMaxSelections && renderDropdown && isOpen,
          }"
        >
          <div class="input-search">
            <div class="wrapper-all-inside-input">
              <div class="p-0 wrapper-input wrapper-input-squid text-ellipsisarea">
                <input
                  [dataTest]="selectInputDataTest"
                  [name]="name"
                  [id]="id"
                  [placeholder]="searchPlaceholder || 'Buscar...'"
                  class="col input"
                  [value]="searchText"
                  (input)="onSearchInput($event)"
                />
              </div>
              <span class="icon icon-external textarea-icon"><i class="fas fa-search"></i></span>
            </div>
          </div>

          <cdk-virtual-scroll-viewport
            [itemSize]="32"
            [ngStyle]="{ height: '305px' }"
            class="list scrollbar"
          >
            <ng-container
              *cdkVirtualFor="let opt of displayOptions; i as index; trackBy: trackByValue"
            >
              <ng-template *ngTemplateOutlet="option; context: { opt: opt, i: index }"></ng-template>
            </ng-container>
          </cdk-virtual-scroll-viewport>
          @if (!displayOptions?.length) {
            @if (!emptyTemplate) {
              <p class="mb-0 mt-3">
                Nenhuma opção encontrada
              </p>
            }
            @if (emptyTemplate) {
              <span>
                <ng-container *ngTemplateOutlet="emptyTemplate"></ng-container>
              </span>
            }
          }
        </div>
      }
    </div>
  </div>

  <!-- Template de opção (recursivo para hierarquia) -->
  <ng-template #option let-opt="opt" let-i="i">
    <li>
      <div class="label">
        <i
          class="icon-collapse fas fa-chevron-down"
          [ngClass]="{ 'fa-rotate-by': !opt.open }"
          [ngStyle]="{ color: !opt?.children?.length || opt?.disabled ? 'transparent' : '' }"
          (click)="toggleCollapse(opt)"
          style="--fa-rotate-angle: -90deg"
        ></i>
        <sq-selector
          [dataTest]="optionDataTest + '-' + (i || opt.value)"
          [style.minWidth]="'auto'"
          [id]="(id || name) + '-checkbox-' + opt?.value + '-' + i"
          [name]="name + '-checkbox'"
          [value]="opt?.value"
          [disabled]="opt?.disabled"
          [checked]="isSelected(opt)"
          [indeterminate]="hasSelectedChildren(opt) && !isSelected(opt)"
          (valueChange)="toggleOption(opt, $event)"
        ></sq-selector>
        <span
          class="text m-0 display-inline-block"
          [ngClass]="{ 'cursor-pointer': opt?.children?.length }"
          (click)="toggleCollapse(opt)"
          >{{ opt?.label }}</span
        >
      </div>
      @if (opt?.children?.length) {
        <ul class="children" [ngClass]="{ open: !opt?.disabled && opt?.open }">
          @for (
            child of opt?.children;
            track trackByValue(j, child);
            let j = $index
          ) {
            <ng-template *ngTemplateOutlet="option; context: { opt: child, i: j }"></ng-template>
          }
        </ul>
      }
    </li>
  </ng-template>
</div>


./sq-select-multi-form-control.component.scss

// Estilos para sq-select-multi-form-control (compatível com sq-select-multi-tags)

.wrapper-all-inside-input {
  position: relative;
  display: block;
}

.wrapper-select-multi {
  margin: 0;
  padding: 0;
  text-align: left;
  display: flex;
  flex-direction: column;
  position: relative;

  &.loading {
    cursor: wait;
  }

  .input-fake-content-text {
    display: flex;
    justify-content: flex-start;
    align-items: center;
    flex-wrap: wrap;
    gap: 5px;
    max-width: calc(100% - 40px);
    min-height: 51px;

    .tag {
      margin: 0;
      border: 1px solid var(--border_color);
      border-radius: 5px;
      padding: 0.2rem 0.35rem;
      display: flex;
      align-items: center;
      gap: 5px;
      cursor: pointer;
      transition: var(--transition);
      background: var(--background_secondary);

      &:hover {
        background: var(--border_color);
      }
    }
  }

  .input-fake-content {
    min-height: 44px;
    border: 1px solid var(--border_color);
    background: var(--background);
    padding: 0.75rem 1rem;
    transition: var(--transition);
    color: var(--text_color);
    border-radius: 5px;
    overflow-x: auto;

    .icon-down {
      right: 20px;
      bottom: calc(50% - 6px);
      position: absolute;
      font-size: 0.86rem;
    }
    &.readonly {
      cursor: not-allowed;
      pointer-events: none;
      background-color: var(--color_border_input);
      border-color: var(--color_border_input_disabled);
    }
  }

  .loading-wrapper {
    position: absolute;
    right: 5px;
    bottom: 10px;
  }

  .badge {
    position: absolute;
    bottom: 15px;
    right: 20px;
    margin: 0;
    border-radius: 50%;
    background-color: var(--border_color);
    color: var(--black);
    font-size: 0.86rem;
    font-weight: 700;
    padding: 0 0.35rem;
    border-radius: 50%;
    min-width: 20px;
    height: 20px;
    line-height: 20px;
    text-align: center;
    transition: var(--transition);
  }

  .input-search {
    display: inline-block;
    position: sticky;
    top: 0;
    z-index: 1;
    width: 100%;
    padding-top: 1.1rem;
    margin-bottom: 0.7rem;
    background-color: var(--background_secondary);

    .wrapper-all-inside-input {
      .icon {
        margin: 0;
        font-size: 1rem;
        font-weight: 700;
        position: absolute;
        right: 11px;
        top: 16px;
      }

      .icon-external {
        color: inherit !important;
      }
    }
  }

  .input-window {
    width: 100%;
    position: absolute;
    top: 100%;
    max-height: 0;
    height: 0;
    transition: var(--transition);
    overflow: hidden;
    z-index: 1;

    &.open {
      height: auto;
      max-height: 400px;
      background: var(--background_secondary);
      padding: 0 0.7rem 1.1rem;
      overflow-y: auto;
      box-shadow: var(--box_shadow);
    }
  }

  .icon-collapse {
    transition: var(--transition);
    cursor: pointer;
    padding: 0.35rem;
    position: relative;
  }

  .list,
  .children {
    padding: 0;
    list-style: none;
    margin: 0;
    width: 100%;

    li {
      display: grid;
      .label {
        margin: 0 0 0.5rem;
        display: flex;
        flex-wrap: nowrap;
        align-items: baseline;
        justify-content: flex-start;
        transition: var(--transition);
      }
    }
  }

  .children {
    max-height: 0;
    overflow: hidden;
    transition: var(--transition);
    padding-left: 1.4rem;

    &.open {
      max-height: 9999px;
    }
  }
}


Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""