/// /// Copyright 2015-2026 Micro Focus or one of its affiliates. /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. /// You may obtain a copy of the License at /// /// http://www.apache.org/licenses/LICENSE-2.0 /// /// Unless required by applicable law or agreed to in writing, software /// distributed under the License is distributed on an "AS IS" BASIS, /// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. /// See the License for the specific language governing permissions and /// limitations under the License. /// import { animate, AnimationEvent, state, style, transition, trigger } from '@angular/animations'; import { isPlatformServer } from '@angular/common'; import { AfterContentInit, ChangeDetectionStrategy, Component, ContentChild, ElementRef, EventEmitter, HostListener, inject, Input, OnDestroy, Output, PLATFORM_ID, Renderer2, } from '@angular/core'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { ColorService } from '../../services/color/color.service'; import { ToolbarSearchButtonDirective } from './toolbar-search-button.directive'; import { ToolbarSearchFieldDirective } from './toolbar-search-field.directive'; @Component({ selector: 'ux-toolbar-search', template: '', changeDetection: ChangeDetectionStrategy.OnPush, animations: [ trigger('expanded', [ state('collapsed', style({ width: '{{initialWidth}}' }), { params: { initialWidth: '30px' }, }), state('expanded', style({ width: '100%' })), transition('collapsed <=> expanded', [animate('0.3s ease-out')]), ]), ], host: { '[class.expanded]': 'expanded', '[class.left]': 'direction === "left"', '[class.right]': 'direction === "right"', '[class.inverse]': 'inverse', '[style.position]': '_position', '[style.background-color]': '_backgroundColor', '[@expanded]': '_expandedAnimation', }, }) export class ToolbarSearchComponent implements AfterContentInit, OnDestroy { private readonly _platformId = inject(PLATFORM_ID); private readonly _elementRef = inject(ElementRef); private readonly _colorService = inject(ColorService); private readonly _renderer = inject(Renderer2); /** The direction in which the search box will expand. If the search button is aligned to the right edge of the container, specify left. */ @Input() direction: 'left' | 'right' = 'right'; /** Whether the color scheme is inverted. For use when the component is hosted on a dark background, e.g. the masthead. */ @Input() inverse = false; /** Indicate whether or not the search field should always be expanded */ @Input() alwaysExpanded: boolean = false; /** Whether the input field is visible. Use this to collapse or expand the control in response to other events. */ @Input() set expanded(value: boolean) { this._expanded = value; this.expandedChange.emit(this.expanded); if (this.expanded) { // Set focus on the input when expanded this.field.focus(); } else { // Clear text when contracted this.field.clear(); // Remove focus (works around an IE issue where the caret remains visible) this.field.blur(); } } get expanded(): boolean { return this.alwaysExpanded || this._expanded; } /* * The background color of the component. Color names from the Color Palette can be used here. * Specify this when a transparent background would cause display issues, such as background items showing through the search field. */ @Input() set background(value: string) { this._backgroundColor = this._colorService.resolve(value) || 'transparent'; } /** Emitted when the expanded state changes */ @Output() expandedChange = new EventEmitter(); /** * Emitted when a search query has been submitted, either by pressing enter when the search field has focus, or by clicking the search button * when the search field contains text. The event contains the search text. */ // eslint-disable-next-line @angular-eslint/no-output-native @Output() search = new EventEmitter(); /** Return the correct animation based on the expanded state */ get _expandedAnimation() { return { value: this.expanded ? 'expanded' : 'collapsed', params: { initialWidth: this.button.width + 'px', }, }; } /** Access the input field element */ @ContentChild(ToolbarSearchFieldDirective, { static: true }) field: ToolbarSearchFieldDirective; /** Access the search button element */ @ContentChild(ToolbarSearchButtonDirective, { static: false }) button: ToolbarSearchButtonDirective; /** Store the CSS position value as this may change to absolute */ _position: string = 'relative'; /** Store the active background color */ _backgroundColor: string = 'transparent'; /** Store the expanded state */ private _expanded: boolean = false; /** Store the programmatically created placeholder element */ private _placeholder: HTMLElement; /** Unsubscribe from all subscriptions on component destroy */ private readonly _onDestroy = new Subject(); ngAfterContentInit(): void { // Subscribe to the submitted event on the input field, triggering the search event this.field.submitted .pipe(takeUntil(this._onDestroy)) .subscribe((text: string) => this.search.emit(text)); // Subscribe to cancel events coming from the input field this.field.cancel.pipe(takeUntil(this._onDestroy)).subscribe(() => (this.expanded = false)); // Subscribe to the button click event this.button.clicked.pipe(takeUntil(this._onDestroy)).subscribe(() => { this.expanded && this.field.text ? this.search.emit(this.field.text) : (this.expanded = !this.expanded); }); // Create placeholder element to avoid changing layout when switching to position: absolute // If the platform is a server we dont want to do this as we can't access getComputedStyle if (!isPlatformServer(this._platformId)) { this.createPlaceholder(); } } ngOnDestroy(): void { this._onDestroy.next(); this._onDestroy.complete(); /** * We programmatically created the placeholder node so Angular is not aware of its existence * so we must manually destroy it otherwise the reference will be retained. * Note, the `destroyNode` function may be null or undefined as mentioned in the * Angular API docs (https://angular.io/api/core/Renderer2#destroyNode) so * we must check that the function is available before attempting to call it */ if (this._placeholder && this._renderer && this._renderer.destroyNode) { this._renderer.destroyNode(this._placeholder); } } @HostListener('@expanded.start', ['$event']) animationStart(event: AnimationEvent): void { if (event.toState === 'expanded') { this._position = 'absolute'; this.setPlaceholderVisible(true); } } @HostListener('@expanded.done', ['$event']) animationDone(event: AnimationEvent): void { if (event.toState === 'collapsed') { this._position = 'relative'; this.setPlaceholderVisible(false); } } /** Programmatically create a placeholder element */ private createPlaceholder(): void { // Create invisible div with the same dimensions this._placeholder = this._renderer.createElement('div'); this._renderer.setStyle(this._placeholder, 'display', 'none'); this._renderer.setStyle(this._placeholder, 'width', this.button.width + 'px'); this._renderer.setStyle(this._placeholder, 'visibility', 'hidden'); this.setPlaceholderHeight(); // Add as a sibling this._renderer.insertBefore( this._elementRef.nativeElement.parentNode, this._placeholder, this._elementRef.nativeElement ); } /** Update the display state of the placeholder node */ private setPlaceholderVisible(isVisible: boolean): void { if (!this._placeholder) { return; } // Recalculate the height since the layout might not be complete when initially created. if (isVisible) { this.setPlaceholderHeight(); } this._renderer.setStyle(this._placeholder, 'display', isVisible ? 'inline-block' : 'none'); } /** Set the placeholder height to match the height of this component. */ private setPlaceholderHeight(): void { const { height } = getComputedStyle(this._elementRef.nativeElement); this._renderer.setStyle(this._placeholder, 'height', height); } }