All files / element-ng/filtered-search si-filtered-search-input.component.ts

98.8% Statements 83/84
97.67% Branches 42/43
94.11% Functions 16/17
100% Lines 74/74

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211                                                                852x 1x     171x     171x     171x     171x     171x     171x   171x     171x     171x     171x     171x     171x     171x     171x     171x             171x               171x     171x     171x     171x     171x   171x   171x       22x 22x     171x 183x       171x     1x 1x         239x 239x 11x 11x     228x 228x   228x 279x 279x 279x 201x 77x   201x     78x 78x 59x 19x 17x   78x 17x   61x     228x 228x       45x 45x 3x           17x 17x   17x 17x 17x 17x 17x       6x 5x               59x 59x       49x   59x       23x 23x 23x      
/**
 * Copyright (c) Siemens 2016 - 2026
 * SPDX-License-Identifier: MIT
 */
import { ScrollStrategy } from '@angular/cdk/overlay';
import {
  afterNextRender,
  Component,
  computed,
  ElementRef,
  inject,
  Injector,
  input,
  model,
  output,
  viewChild
} from '@angular/core';
import { SiTypeaheadDirective, TypeaheadOption } from '@siemens/element-ng/typeahead';
import { SiTranslatePipe, TranslatableString } from '@siemens/element-translate-ng/translate';
import { Observable } from 'rxjs';
 
import { InternalCriterionDefinition } from './si-filtered-search-helper';
 
@Component({
  selector: 'si-filtered-search-input',
  imports: [SiTypeaheadDirective, SiTranslatePipe],
  templateUrl: './si-filtered-search-input.component.html',
  styleUrl: './si-filtered-search-input.component.scss',
  host: {
    class: 'w-100'
  }
})
export class SiFilteredSearchInputComponent {
  private static readonly criterionRegex = /(.+?):(.*)$/;
 
  /** Supplies the criterion definitions shown in the typeahead. */
  readonly dataSource = input.required<Observable<InternalCriterionDefinition[]>>();
 
  /** Limits the number of criterion options displayed in the typeahead. */
  readonly typeaheadOptionsLimit = input.required<number>();
 
  /** Sets the number of options visible before the typeahead scrolls. */
  readonly optionsInScrollableView = input.required<number>();
 
  /** Whether the search input is disabled. */
  readonly disabled = input.required<boolean>();
 
  /** Placeholder text shown when the search input is empty. */
  readonly placeholder = input.required<string>();
 
  /** Accessible label for the search input. */
  readonly searchLabel = input.required<TranslatableString>();
  /** Factory for the CDK scroll strategy used by the criterion typeahead overlay. */
  readonly scrollStrategyFactory = input.required<() => ScrollStrategy>();
 
  /** Current search text. Supports two-way binding through `searchValueChange`. */
  readonly searchValue = model.required<string>();
 
  /** Whether the current criterion accepts free-text values. */
  readonly freeTextCriterion = input.required<boolean>();
 
  /** Whether the configured maximum number of criteria has been reached. */
  readonly maxCriteriaReached = input.required<boolean>();
 
  /** Whether users can create a free-text criterion. */
  readonly allowFreeText = input.required<boolean>();
 
  /** Label for the typeahead option that creates a free-text criterion. */
  readonly searchForFreeTextLabel = input.required<TranslatableString>();
 
  /** Whether typing a colon or semicolon must not select a criterion. */
  readonly disableSelectionByColonAndSemicolon = input.required<boolean>();
 
  /** Whether the input only permits selecting values from the typeahead. */
  readonly onlySelectValue = input.required<boolean>();
 
  /** Emits the selected criterion and an optional value typed with it. */
  readonly createCriterion = output<{
    criterion: InternalCriterionDefinition;
    value?: string;
    accept: () => void;
  }>();
 
  /** Emits a criterion name and optional value parsed from the input text. */
  readonly createCriterionByName = output<{
    criterionName: string;
    value?: string;
    editOnCreation?: boolean;
    accept: () => void;
  }>();
 
  /** Emits when Backspace is pressed in an empty search input. */
  readonly backspaceOverflow = output();
 
  /** Emits the text used to create a free-text criterion. */
  readonly createFreeTextPill = output<{ query: string; accept: () => void }>();
 
  /** Emits when the search input receives focus. */
  readonly inputFocus = output();
 
  /** Emits when Enter is pressed in the search input. */
  readonly enterSubmit = output();
 
  private readonly inputElement =
    viewChild.required<ElementRef<HTMLInputElement>>('freeTextInputElement');
 
  private readonly typeahead = viewChild.required(SiTypeaheadDirective);
 
  private readonly injector = inject(Injector);
 
  /** Public method to focus the input element */
  focus(): void {
    this.inputElement().nativeElement.focus();
    afterNextRender(() => this.typeahead().updateOverlayPosition(), { injector: this.injector });
  }
 
  protected readonly typeaheadCreateOption = computed(() =>
    this.freeTextCriterion() && !this.maxCriteriaReached() && this.allowFreeText()
      ? this.searchForFreeTextLabel()
      : undefined
  );
  protected readonly typeaheadScrollStrategy = computed(() => this.scrollStrategyFactory()());
 
  protected freeTextBackspaceHandler(event: Event): void {
    Eif (!(event.target as HTMLInputElement).value) {
      this.backspaceOverflow.emit();
    }
  }
 
  protected freeTextInputHandler(event: Event): void {
    const inputElement = event.target as HTMLInputElement;
    if (this.disableSelectionByColonAndSemicolon()) {
      this.searchValue.set(inputElement.value);
      return;
    }
 
    const tokens = inputElement.value.split(';');
    let consumedTokenCount = 0;
 
    for (const [index, token] of tokens.entries()) {
      const isLastToken = index === tokens.length - 1;
      const criterionMatch = token.match(SiFilteredSearchInputComponent.criterionRegex);
      if (isLastToken && !criterionMatch) {
        if (!token) {
          consumedTokenCount++;
        }
        break;
      }
 
      let accepted = false;
      if (!this.onlySelectValue() && criterionMatch) {
        accepted = this.requestCriterionByName(criterionMatch, isLastToken);
      } else if (token && this.freeTextCriterion()) {
        accepted = this.requestFreeTextPill(token);
      }
      if (!accepted) {
        break;
      }
      consumedTokenCount++;
    }
 
    inputElement.value = tokens.slice(consumedTokenCount).join(';');
    this.searchValue.set(inputElement.value);
  }
 
  protected freeTextBlurHandler(): void {
    queueMicrotask(() => {
      if (this.freeTextCriterion() && this.searchValue().length > 0) {
        this.createFreeTextPillHandler(this.searchValue());
      }
    });
  }
 
  protected typeaheadOnSelectCriterionHandler(event: TypeaheadOption): void {
    const criterion = event as InternalCriterionDefinition;
    const inputElement = this.inputElement().nativeElement;
    // Removes the focus border before creating a new criterion to prevent the impression of jumping content.
    inputElement.blur();
    let accepted = false;
    this.createCriterion.emit({ criterion, accept: () => (accepted = true) });
    inputElement.value = accepted ? '' : this.typeahead().query();
    this.searchValue.set(inputElement.value);
  }
 
  protected createFreeTextPillHandler(query: string): void {
    if (this.requestFreeTextPill(query)) {
      this.searchValue.set('');
    }
  }
 
  private requestCriterionByName(
    criterionMatch: RegExpMatchArray,
    editOnCreation: boolean
  ): boolean {
    let accepted = false;
    this.createCriterionByName.emit({
      criterionName: criterionMatch[1].trim(),
      value: criterionMatch[2].trim(),
      editOnCreation,
      accept: () => (accepted = true)
    });
    return accepted;
  }
 
  private requestFreeTextPill(query: string): boolean {
    let accepted = false;
    this.createFreeTextPill.emit({ query, accept: () => (accepted = true) });
    return accepted;
  }
}