/** * Copyright since 2025 Mifos Initiative * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { Component, ElementRef, forwardRef, HostListener, Input, ViewChild } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'; export interface SearchSelectOption { value: string; label: string; /** What typed text is matched against — defaults to `label` when omitted, but a caller can * pass a richer string (e.g. account no + customer name + branch) so a search hits fields * that aren't all visible in the collapsed label. */ searchText?: string; } /** * Drop-in replacement for a plain ` @if (open) { } `, styles: [ ` /* Angular view encapsulation stops the host page's shared styles from reaching inside this * component's own template, so its look is re-declared here rather than inherited. */ :host { display: block; width: 100%; } .inv-search-select { position: relative; width: 100%; } .inv-search-select__input { width: 100%; height: 36px; padding: 0 10px; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 13px; color: #0d1b2a; font-family: inherit; background: #fff; box-sizing: border-box; } /* position: fixed, anchored via JS to the input's own screen coords (see * updateListPosition()) rather than absolute inside this component — most of this app's * cards use overflow: hidden for rounded corners, which would otherwise clip a dropdown * that extends past the card's edge. */ .inv-search-select__list { position: fixed; z-index: 2000; max-height: 260px; overflow-y: auto; margin: 0; padding: 4px; list-style: none; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; box-shadow: 0 8px 24px rgba(13, 27, 42, 0.18); } .inv-search-select__option { padding: 8px 10px; border-radius: 6px; font-size: 13px; color: #0d1b2a; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .inv-search-select__option:hover, .inv-search-select__option--active { background: #eef2ff; } .inv-search-select__empty { padding: 8px 10px; font-size: 12px; color: #94a3b8; } ` ] }) export class SearchSelectComponent implements ControlValueAccessor { @Input() options: SearchSelectOption[] = []; @Input() placeholder = 'Search…'; @ViewChild('inputEl') private inputEl?: ElementRef; value = ''; query = ''; open = false; disabled = false; listTop = 0; listLeft = 0; listWidth = 0; private onChangeFn: (v: string) => void = () => {}; onTouched: () => void = () => {}; get filteredOptions(): SearchSelectOption[] { const q = this.query.trim().toLowerCase(); if (!q) { return this.options; } return this.options.filter((o) => (o.searchText || o.label).toLowerCase().includes(q)); } private labelFor(value: string): string { return this.options.find((o) => o.value === value)?.label ?? ''; } writeValue(v: string): void { this.value = v ?? ''; this.query = this.labelFor(this.value); } registerOnChange(fn: (v: string) => void): void { this.onChangeFn = fn; } registerOnTouched(fn: () => void): void { this.onTouched = fn; } setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; } /** Anchors the fixed-position list to the input's current on-screen box. */ private updateListPosition(): void { const el = this.inputEl?.nativeElement; if (!el) { return; } const rect = el.getBoundingClientRect(); this.listTop = rect.bottom + 4; this.listLeft = rect.left; this.listWidth = rect.width; } @HostListener('window:scroll') @HostListener('window:resize') onWindowChange(): void { if (this.open) { this.updateListPosition(); } } onInput(event: Event): void { this.query = (event.target as HTMLInputElement).value; this.open = true; this.updateListPosition(); } onFocus(): void { this.open = true; this.updateListPosition(); } onBlur(): void { this.open = false; this.onTouched(); // Discard any uncommitted typed text — only a clicked option ever changes `value`. this.query = this.labelFor(this.value); } selectOption(o: SearchSelectOption): void { this.value = o.value; this.query = o.label; this.open = false; this.onChangeFn(this.value); this.onTouched(); } }