File

src/components/sq-textarea-form-control/sq-textarea-form-control.component.ts

Description

Componente de textarea com Reactive Forms.

Substitui o componente legado sq-textarea com integração nativa de Reactive Forms, ControlValueAccessor e Validator.

Example :
```html
<sq-textarea-form-control
  [label]="'Descrição'"
  [placeholder]="'Digite uma descrição...'"
  [formControl]="descriptionControl"
  [maxLength]="500"
></sq-textarea-form-control>
Example :
```html
```html
<!-- Com validação required -->
<sq-textarea-form-control
  [label]="'Comentário *'"
  [formControl]="commentControl"
  sqValidation
  [fieldName]="'Comentário'"
></sq-textarea-form-control>
Example :

Implements

ControlValueAccessor Validator OnInit OnDestroy

Metadata

Index

Properties
Methods
Inputs
Outputs
Accessors

Constructor

constructor()

Construtor do componente.

Inputs

autoResize
Type : boolean
Default value : false

Se true, o textarea se expande automaticamente.

backgroundColor
Type : string
Default value : ''

Cor de fundo do textarea.

borderColor
Type : string
Default value : ''

Cor da borda do textarea.

customClass
Type : string
Default value : ''

Classe CSS customizada.

debounceTime
Type : number
Default value : 0

Debounce do valueChange em ms.

disabled
Type : boolean
Default value : false

Desabilita o textarea.

errorSpan
Type : boolean
Default value : true

Exibe o span de erro.

externalError
Type : string
Default value : ''

Erro externo para exibição.

externalIcon
Type : string
Default value : ''

Ícone externo.

id
Type : string
Default value : `textarea-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`

ID do elemento textarea.

label
Type : string
Default value : ''

Label do campo.

labelColor
Type : string
Default value : ''

Cor da label.

maxHeight
Type : string
Default value : '300px'

Altura máxima do textarea (quando autoResize=true).

maxLength
Type : number | null
Default value : null

Comprimento máximo do texto.

minHeight
Type : string
Default value : '100px'

Altura mínima do textarea (quando autoResize=true).

minLength
Type : number | null
Default value : null

Comprimento mínimo do texto.

name
Type : string
Default value : ''

Nome do campo.

placeholder
Type : string
Default value : ''

Placeholder do textarea.

readonly
Type : boolean
Default value : false

Modo somente leitura.

required
Type : boolean
Default value : false

Campo obrigatório (adiciona validação required).

rows
Type : number
Default value : 4

Número de linhas do textarea.

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.

Outputs

blurred
Type : EventEmitter

Evento de blur.

focused
Type : EventEmitter

Evento de foco.

keyPressDown
Type : EventEmitter

Evento de tecla pressionada.

keyPressUp
Type : EventEmitter

Evento de tecla solta.

Methods

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
onInput
onInput(event: Event)

Handler para auto-resize.

Parameters :
Name Type Optional Description
event Event No
  • Evento de input.
Returns : void
onKeyDown
onKeyDown(event: KeyboardEvent)

Handler de keydown.

Parameters :
Name Type Optional Description
event KeyboardEvent No
  • Evento de teclado.
Returns : void
onKeyUp
onKeyUp(event: KeyboardEvent)

Handler de keyup.

Parameters :
Name Type Optional Description
event KeyboardEvent No
  • Evento de teclado.
Returns : void
registerOnChange
registerOnChange(fn: (value: string) => 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
setDisabledState
setDisabledState(isDisabled: boolean)

Define estado disabled.

Parameters :
Name Type Optional Description
isDisabled boolean No
  • Se está desabilitado.
Returns : void
Private setupValidators
setupValidators()

Configura os validadores baseado nos inputs.

Returns : void
validate
validate()

Valida o controle.

Returns : ValidationErrors | null

Erros de validação ou null.

writeValue
writeValue(value: string | null)

Escreve o valor no controle.

Parameters :
Name Type Optional Description
value string | null No
  • Valor a ser escrito.
Returns : void

Properties

Private cdr
Default value : inject(ChangeDetectorRef)

Referência ao ChangeDetectorRef.

control
Default value : new FormControl<string>('')

FormControl interno.

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

Subject para cleanup.

Private elementRef
Default value : inject(ElementRef)

Referência ao ElementRef.

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

Template customizado para o label.

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

Template para label à esquerda.

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.

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

Template para label à direita.

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

Subject para debounce.

Accessors

value
getvalue()

Retorna o valor atual.

Returns : string
remainingChars
getremainingChars()

Retorna o número de caracteres restantes.

Returns : number
hasError
gethasError()

Verifica se há erro.

Returns : boolean
import {
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  ContentChild,
  ElementRef,
  EventEmitter,
  forwardRef,
  inject,
  Input,
  OnDestroy,
  OnInit,
  Output,
  TemplateRef,
} from '@angular/core';
import { NgClass, NgStyle, NgTemplateOutlet } from '@angular/common';
import {
  ControlValueAccessor,
  FormControl,
  NG_VALIDATORS,
  NG_VALUE_ACCESSOR,
  ReactiveFormsModule,
  ValidationErrors,
  Validator,
  Validators,
} from '@angular/forms';
import { Subject, debounceTime, distinctUntilChanged, takeUntil } from 'rxjs';
import { SqTooltipComponent } from '../sq-tooltip/sq-tooltip.component';
import { UniversalSafePipe } from '../../pipes/universal-safe/universal-safe.pipe';

/**
 * Componente de textarea com Reactive Forms.
 *
 * Substitui o componente legado `sq-textarea` com integração nativa de Reactive Forms,
 * ControlValueAccessor e Validator.
 *
 * @example
 * ```html
 * <sq-textarea-form-control
 *   [label]="'Descrição'"
 *   [placeholder]="'Digite uma descrição...'"
 *   [formControl]="descriptionControl"
 *   [maxLength]="500"
 * ></sq-textarea-form-control>
 * ```
 *
 * @example
 * ```html
 * <!-- Com validação required -->
 * <sq-textarea-form-control
 *   [label]="'Comentário *'"
 *   [formControl]="commentControl"
 *   sqValidation
 *   [fieldName]="'Comentário'"
 * ></sq-textarea-form-control>
 * ```
 */
@Component({
  selector: 'sq-textarea-form-control',
  templateUrl: './sq-textarea-form-control.component.html',
  styleUrls: ['./sq-textarea-form-control.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true,
  imports: [NgClass, NgStyle, NgTemplateOutlet, ReactiveFormsModule, SqTooltipComponent, UniversalSafePipe],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => SqTextareaFormControlComponent),
      multi: true,
    },
    {
      provide: NG_VALIDATORS,
      useExisting: forwardRef(() => SqTextareaFormControlComponent),
      multi: true,
    },
  ],
})
export class SqTextareaFormControlComponent implements ControlValueAccessor, Validator, OnInit, OnDestroy {
  // ============================================================
  // Inputs - Identificação
  // ============================================================

  /**
   * ID do elemento textarea.
   */
  @Input() id = `textarea-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;

  /**
   * Nome do campo.
   */
  @Input() name = '';

  // ============================================================
  // Inputs - Aparência
  // ============================================================

  /**
   * Label do campo.
   */
  @Input() label = '';

  /**
   * Placeholder do textarea.
   */
  @Input() placeholder = '';

  /**
   * Classe CSS customizada.
   */
  @Input() customClass = '';

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

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

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

  /**
   * Número de linhas do textarea.
   */
  @Input() rows = 4;

  /**
   * Se true, o textarea se expande automaticamente.
   */
  @Input() autoResize = false;

  /**
   * Altura mínima do textarea (quando autoResize=true).
   */
  @Input() minHeight = '100px';

  /**
   * Altura máxima do textarea (quando autoResize=true).
   */
  @Input() maxHeight = '300px';

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

  /**
   * Desabilita o textarea.
   */
  @Input() disabled = false;

  /**
   * Modo somente leitura.
   */
  @Input() readonly = false;

  /**
   * Campo obrigatório (adiciona validação required).
   */
  @Input() required = false;

  /**
   * Comprimento máximo do texto.
   */
  @Input() maxLength: number | null = null;

  /**
   * Comprimento mínimo do texto.
   */
  @Input() minLength: number | null = null;

  /**
   * Debounce do valueChange em ms.
   */
  @Input() debounceTime = 0;

  /**
   * Exibe o span de erro.
   */
  @Input() errorSpan = true;

  /**
   * Erro externo para exibição.
   */
  @Input() externalError = '';

  /**
   * Ícone externo.
   */
  @Input() externalIcon = '';

  // ============================================================
  // 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 = '';

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

  /**
   * Evento de tecla pressionada.
   */
  @Output() keyPressDown = new EventEmitter<KeyboardEvent>();

  /**
   * Evento de tecla solta.
   */
  @Output() keyPressUp = new EventEmitter<KeyboardEvent>();

  /**
   * Evento de foco.
   */
  @Output() focused = new EventEmitter<FocusEvent>();

  /**
   * Evento de blur.
   */
  @Output() blurred = new EventEmitter<FocusEvent>();

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

  /**
   * Template para label à esquerda.
   */
  @ContentChild('leftLabel') leftLabel: TemplateRef<HTMLElement> | null = null;

  /**
   * Template para label à direita.
   */
  @ContentChild('rightLabel') rightLabel: TemplateRef<HTMLElement> | null = null;

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

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

  /**
   * FormControl interno.
   */
  control = new FormControl<string>('');

  /**
   * Subject para debounce.
   */
  private valueSubject = new Subject<string>();

  /**
   * Subject para cleanup.
   */
  private destroy$ = new Subject<void>();

  /**
   * Callback de mudança de valor.
   */
  // eslint-disable-next-line @typescript-eslint/no-empty-function
  private onChange: (value: string) => 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 mudança de valor com debounce opcional
    this.valueSubject
      .pipe(
        debounceTime(this.debounceTime),
        distinctUntilChanged(),
        takeUntil(this.destroy$)
      )
      .subscribe(value => {
        this.onChange(value);
      });
  }

  /**
   * Inicialização do componente.
   */
  ngOnInit(): void {
    this.setupValidators();

    // Subscription para mudanças do controle interno
    this.control.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(value => {
      if (this.debounceTime > 0) {
        this.valueSubject.next(value || '');
      } else {
        this.onChange(value || '');
      }
    });
  }

  /**
   * 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: string | null): void {
    this.control.setValue(value || '', { emitEvent: false });
    this.cdr.markForCheck();
  }

  /**
   * Registra callback de mudança de valor.
   *
   * @param fn - Callback.
   */
  registerOnChange(fn: (value: string) => 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 {
    this.disabled = isDisabled;
    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 {
    return this.control.errors;
  }

  /**
   * Registra callback para mudança de validação.
   *
   * @param fn - Callback.
   */
  registerOnValidatorChange(fn: () => void): void {
    this.onValidationChange = fn;
  }

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

  /**
   * Retorna o valor atual.
   */
  get value(): string {
    return this.control.value || '';
  }

  /**
   * Retorna o número de caracteres restantes.
   */
  get remainingChars(): number {
    if (!this.maxLength) return 0;
    return this.maxLength - (this.value?.length || 0);
  }

  /**
   * Verifica se há erro.
   */
  get hasError(): boolean {
    return !!(this.externalError || (this.control.invalid && this.control.touched));
  }

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

  /**
   * Handler de blur.
   *
   * @param event - Evento de blur.
   */
  onBlur(event: FocusEvent): void {
    this.onTouched();
    this.blurred.emit(event);
  }

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

  /**
   * Handler de keydown.
   *
   * @param event - Evento de teclado.
   */
  onKeyDown(event: KeyboardEvent): void {
    this.keyPressDown.emit(event);
  }

  /**
   * Handler de keyup.
   *
   * @param event - Evento de teclado.
   */
  onKeyUp(event: KeyboardEvent): void {
    this.keyPressUp.emit(event);
  }

  /**
   * Handler para auto-resize.
   *
   * @param event - Evento de input.
   */
  onInput(event: Event): void {
    if (this.autoResize) {
      const textarea = event.target as HTMLTextAreaElement;
      textarea.style.height = 'auto';
      textarea.style.height = `${textarea.scrollHeight}px`;
    }
  }

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

  /**
   * Configura os validadores baseado nos inputs.
   */
  private setupValidators(): void {
    const validators = [];

    if (this.required) {
      validators.push(Validators.required);
    }

    if (this.maxLength) {
      validators.push(Validators.maxLength(this.maxLength));
    }

    if (this.minLength) {
      validators.push(Validators.minLength(this.minLength));
    }

    if (validators.length > 0) {
      this.control.setValidators(validators);
      this.control.updateValueAndValidity({ emitEvent: false });
    }
  }
}

<div class="sq-textarea-form-control {{ customClass }}">
  <!-- Label -->
  @if (label?.length || tooltipMessage || labelTemplate) {
    <div
      class="display-flex align-items-center wrapper-label-input"
      [ngClass]="{ disabled: disabled, readonly: readonly }"
      [ngStyle]="{ color: labelColor }"
    >
      @if (label && !labelTemplate) {
        <label class="label-input mr-1" [for]="id" [innerHtml]="label | universalSafe"></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 do textarea -->
  <div
    class="textarea-container"
    [ngClass]="{
      error: hasError,
      disabled: disabled,
      readonly: readonly,
    }"
  >
    <!-- Left label -->
    @if (leftLabel) {
      <span class="input-group-text left">
        <ng-container *ngTemplateOutlet="leftLabel"></ng-container>
      </span>
    }

    <!-- Textarea -->
    <textarea
      class="textarea scrollbar"
      [ngStyle]="{
        'background-color': backgroundColor,
        'border-color': borderColor,
        'min-height': autoResize ? minHeight : null,
        'max-height': autoResize ? maxHeight : null,
      }"
      [ngClass]="{
        error: hasError,
        disabled: disabled,
        readonly: readonly,
        'has-icon-external': externalIcon,
        'auto-resize': autoResize,
      }"
      [id]="id"
      [name]="name"
      [placeholder]="placeholder"
      [rows]="rows"
      [disabled]="disabled"
      [readonly]="readonly"
      [maxlength]="maxLength"
      [formControl]="control"
      (blur)="onBlur($event)"
      (focus)="onFocus($event)"
      (keydown)="onKeyDown($event)"
      (keyup)="onKeyUp($event)"
      (input)="onInput($event)"
    ></textarea>

    <!-- Right label -->
    @if (rightLabel) {
      <span class="input-group-text right">
        <ng-container *ngTemplateOutlet="rightLabel"></ng-container>
      </span>
    }

    <!-- External icon -->
    @if (externalIcon) {
      <span
        class="icon-external"
        [ngClass]="{ 'no-label': !label?.length }"
        [innerHtml]="externalIcon | universalSafe"
      ></span>
    }
  </div>

  <!-- Error span e contador -->
  @if (errorSpan) {
    <div
      class="box-validation box-invalid show"
      [ngClass]="{ 'has-max-length': maxLength }"
    >
      @if (hasError) {
        <i class="fa-solid fa-triangle-exclamation"></i>
      }
      <span class="error-text">
        {{ externalError }}
      </span>
      @if (maxLength) {
        <span
          class="max-length-counter"
          [ngClass]="{
            'visibility-hidden-force': disabled || readonly,
            warning: remainingChars < 20 && remainingChars > 0,
            danger: remainingChars <= 0,
          }"
        >
          {{ remainingChars }}
        </span>
      }
    </div>
  }
</div>

./sq-textarea-form-control.component.scss

.sq-textarea-form-control {
  position: relative;
  display: block;
}

// Label wrapper
.wrapper-label-input {
  margin-bottom: 0.25rem;

  &.disabled {
    opacity: 0.6;
  }

  &.readonly {
    opacity: 0.8;
  }

  .label-input {
    font-size: 0.875rem;
    font-weight: 500;
    color: var(--text_color, #333);
  }
}

// Container do textarea
.textarea-container {
  position: relative;
  display: flex;
  border: 1px solid var(--border_color, #ccc);
  border-radius: 5px;
  background-color: var(--background, #fff);
  transition: border-color 0.2s ease, box-shadow 0.2s ease;

  &:focus-within:not(.disabled):not(.readonly) {
    border-color: var(--primary_color, #007bff);
    box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.15);
  }

  &.error {
    border-color: var(--color_error, #dc3545);

    &:focus-within {
      box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.15);
    }
  }

  &.disabled {
    opacity: 0.6;
    background-color: var(--color_bg_input_disabled, #f5f5f5);
    cursor: not-allowed;
  }

  &.readonly {
    background-color: var(--color_bg_input_readonly, #fafafa);
  }
}

// Textarea
.textarea {
  flex: 1;
  width: 100%;
  min-height: 100px;
  padding: 0.625rem 1rem;
  border: none;
  border-radius: 5px;
  font-family: inherit;
  font-size: 0.875rem;
  line-height: 1.5;
  color: var(--text_color, #333);
  background-color: transparent;
  resize: vertical;
  outline: none;
  transition: background-color 0.2s ease;

  &::placeholder {
    color: var(--color_text-icon_neutral_tertiary, #999);
  }

  &.disabled {
    cursor: not-allowed;
    resize: none;
  }

  &.readonly {
    cursor: default;
    resize: none;
  }

  &.has-icon-external {
    padding-right: 2.5rem;
  }

  &.auto-resize {
    resize: none;
    overflow: hidden;
  }
}

// Input group text (left/right labels)
.input-group-text {
  display: flex;
  align-items: flex-start;
  padding: 0.625rem 0.75rem;
  background-color: var(--color_bg_box_neutral_secondary, #f5f5f5);
  border: none;
  font-size: 0.875rem;
  color: var(--text_color, #333);

  &.left {
    border-radius: 5px 0 0 5px;
    border-right: 1px solid var(--border_color, #ccc);
  }

  &.right {
    border-radius: 0 5px 5px 0;
    border-left: 1px solid var(--border_color, #ccc);
  }
}

// External icon
.icon-external {
  position: absolute;
  right: 1rem;
  top: 0.625rem;
  font-size: 1rem;
  color: var(--color_text-icon_neutral_tertiary, #666);

  &.no-label {
    top: 0.625rem;
  }
}

// Validation box
.box-validation {
  display: flex;
  align-items: center;
  justify-content: space-between;
  min-height: 1.25rem;
  margin-top: 0.25rem;
  font-size: 0.75rem;
  color: var(--color_error, #dc3545);

  i {
    margin-right: 0.25rem;
  }

  .error-text {
    flex: 1;
  }
}

// Max length counter
.max-length-counter {
  margin-left: auto;
  font-size: 0.75rem;
  color: var(--color_text-icon_neutral_tertiary, #666);
  font-weight: 500;

  &.warning {
    color: var(--color_warning, #ffc107);
  }

  &.danger {
    color: var(--color_error, #dc3545);
  }
}

.visibility-hidden-force {
  visibility: hidden;
}

Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""