src/app/core/common-components/basic-autocomplete/basic-autocomplete.component.ts
Custom MatFormFieldControl for any select / dropdown field.
| changeDetection | ChangeDetectionStrategy.OnPush |
| providers |
BasicAutocompleteComponent
)
|
| selector | app-basic-autocomplete |
| imports |
BASIC_AUTOCOMPLETE_COMPONENT_IMPORTS
|
| styleUrls | ./basic-autocomplete.component.scss |
| templateUrl | basic-autocomplete.component.html |
Properties |
|
Methods |
|
Inputs |
Outputs |
Accessors |
constructor()
|
| createOption | |
Type : (input: string) => Promise<O>
|
|
| createOptions | |
Type : CreateOptionConfig<O>[]
|
|
Default value : []
|
|
| display | |
Type : "text" | "chips" | "none"
|
|
Default value : "text"
|
|
|
Display the selected items as simple text, as chips or not at all (if used in combination with another component) |
|
| displayFullLengthOptionLabel | |
Default value : false
|
|
|
Whether dropdown option labels should be shown in full length. Set to false to truncate labels with ellipsis. |
|
| hideOption | |
Type : (option: O) => boolean
|
|
Default value : () => false
|
|
| maxOptionsToDisplay | |
Type : number
|
|
Default value : 100
|
|
|
Maximum number of options to display in the dropdown. If more options match the current filter, a hint is shown to type to narrow results. Set to 0 for no limit. Defaults to 100. |
|
| multi | |
Type : boolean
|
|
|
Whether the user should be able to select multiple values. |
|
| optionToString | |
Type : (option: O) => string
|
|
Default value : (option: O) => option?.["_label"] ?? option?.toString(),
|
|
| reorder | |
Type : boolean
|
|
|
Whether the user can manually drag & drop to reorder the selected items |
|
| valueMapper | |
Type : (option: O) => V
|
|
Default value : (option: O) => option?.["_id"] ?? (option as unknown as V),
|
|
| aria-describedby | |
Type : string
|
|
|
Inherited from
CustomFormControlDirective
|
|
|
Defined in
CustomFormControlDirective:98
|
|
| disabled | |
Type : boolean
|
|
|
Inherited from
CustomFormControlDirective
|
|
|
Defined in
CustomFormControlDirective:155
|
|
| ngControl | |
Type : any
|
|
Default value : inject(NgControl, { optional: true, self: true })
|
|
|
Inherited from
CustomFormControlDirective
|
|
|
Defined in
CustomFormControlDirective:91
|
|
| placeholder | |
Type : string
|
|
|
Inherited from
CustomFormControlDirective
|
|
|
Defined in
CustomFormControlDirective:99
|
|
| required | |
Type : boolean
|
|
|
Inherited from
CustomFormControlDirective
|
|
|
Defined in
CustomFormControlDirective:102
|
|
| value | |
Type : T
|
|
|
Inherited from
CustomFormControlDirective
|
|
|
Defined in
CustomFormControlDirective:164
|
|
| autocompleteFilterChange | |
Type : (o: O) => boolean
|
|
| valueChange | |
Type : EventEmitter
|
|
|
Inherited from
CustomFormControlDirective
|
|
|
Defined in
CustomFormControlDirective:172
|
|
| compareEnumValues |
compareEnumValues(a: any, b: any)
|
|
Compare two enum values by id if present, otherwise by reference.
Returns :
boolean
|
| Async createFromConfig | ||||||
createFromConfig(marker: CreateOptionMarker<O>)
|
||||||
|
Parameters :
Returns :
any
|
| Protected createOptionAriaLabel | |||||||||
createOptionAriaLabel(option: CreateOptionConfig<O>, input: string)
|
|||||||||
|
Parameters :
Returns :
string
|
| Protected createOptionDisplay | ||||||
createOptionDisplay(input: string)
|
||||||
|
Used in template to display the "Add new" option label. Delegates to optionToString so callers with a custom optionToString (e.g. showing an example date) get a preview in the "Add new" option too. Falls back to the raw input when optionToString throws or returns null/undefined (e.g. when O is an object type whose properties don't exist on a plain string).
Parameters :
Returns :
string
|
| Protected createOptionLabel | |||||||||
createOptionLabel(option: CreateOptionConfig<O>, input: string)
|
|||||||||
|
Parameters :
Returns :
string
|
| dropChips | ||||||
dropChips(event: CdkDragDrop
|
||||||
|
Parameters :
Returns :
void
|
| onContainerClick | ||||||
onContainerClick(event: MouseEvent)
|
||||||
|
Inherited from
CustomFormControlDirective
|
||||||
|
Defined in
CustomFormControlDirective:696
|
||||||
|
Parameters :
Returns :
void
|
| onFocusOut | ||||||
onFocusOut(event: FocusEvent)
|
||||||
|
Parameters :
Returns :
void
|
| select | ||||||
select(selected: string | SelectableOption
|
||||||
|
Parameters :
Returns :
void
|
| showAutocomplete | ||||||
showAutocomplete(valueToRevertTo?: string)
|
||||||
|
Parameters :
Returns :
void
|
| Protected toCreateOptionValue | |||||||||
toCreateOptionValue(option: CreateOptionConfig<O>, input: string)
|
|||||||||
|
Parameters :
Returns :
CreateOptionMarker<O>
|
| unselect | ||||||
unselect(option: SelectableOption<O | V>)
|
||||||
|
Parameters :
Returns :
void
|
| Public updatePanelWidth |
updatePanelWidth()
|
|
Set the width of the dropdown panel programmatically to match the parent form field. (this is not possible with pure CSS) Note: If the field is close to the viewport edge, Angular Material's overlay system may shift the dropdown horizontally to keep it visible, causing minor misalignment. This is expected and ensures accessibility.
Returns :
void
|
| writeValue | ||||||||||||
writeValue(val: V[] | V, notifyFormControl: unknown)
|
||||||||||||
|
Inherited from
CustomFormControlDirective
|
||||||||||||
|
Defined in
CustomFormControlDirective:707
|
||||||||||||
|
Parameters :
Returns :
void
|
| blur |
blur()
|
|
Inherited from
CustomFormControlDirective
|
|
Defined in
CustomFormControlDirective:201
|
|
Returns :
void
|
| focus |
focus()
|
|
Inherited from
CustomFormControlDirective
|
|
Defined in
CustomFormControlDirective:196
|
|
Returns :
void
|
| registerOnChange | ||||||
registerOnChange(fn: any)
|
||||||
|
Inherited from
CustomFormControlDirective
|
||||||
|
Defined in
CustomFormControlDirective:237
|
||||||
|
Parameters :
Returns :
void
|
| registerOnTouched | ||||||
registerOnTouched(fn: any)
|
||||||
|
Inherited from
CustomFormControlDirective
|
||||||
|
Defined in
CustomFormControlDirective:241
|
||||||
|
Parameters :
Returns :
void
|
| setDescribedByIds | ||||||
setDescribedByIds(ids: string[])
|
||||||
|
Inherited from
CustomFormControlDirective
|
||||||
|
Defined in
CustomFormControlDirective:211
|
||||||
|
Parameters :
Returns :
void
|
| setDisabledState | ||||||
setDisabledState(isDisabled: boolean)
|
||||||
|
Inherited from
CustomFormControlDirective
|
||||||
|
Defined in
CustomFormControlDirective:245
|
||||||
|
Parameters :
Returns :
void
|
| _selectedOptions |
Type : unknown
|
Default value : signal<SelectableOption<O, V>[]>([])
|
| autocomplete |
Type : unknown
|
Default value : viewChild(MatAutocompleteTrigger)
|
| autocompleteFilterFunction |
Type : function
|
| autocompleteForm |
Type : unknown
|
Default value : new FormControl("")
|
| autocompleteOptions |
Type : Signal<SelectableOption[]>
|
Default value : toSignal(
this.autocompleteSuggestedOptions,
{ initialValue: [] as SelectableOption<O, V>[] },
)
|
| hasMoreOptions |
Type : unknown
|
Default value : signal(false)
|
| inputElement |
Type : unknown
|
Default value : viewChild("inputElement", { read: MatInput }) as Signal<
(MatInput & { _elementRef: ElementRef<HTMLElement> }) | undefined
>
|
| isInSearchMode |
Type : WritableSignal<boolean>
|
Default value : signal(false)
|
|
display the search input rather than the selected elements only (when the form field gets focused). |
| panelWidth |
Type : unknown
|
Default value : signal("200px")
|
|
Dynamic width of the autocomplete dropdown panel. Set to match the full width of the Material form field container (including icons/padding). |
| retainSearchValue |
Type : string
|
|
Keep the search value to help users quickly multi-select multiple related options without having to type filter text again |
| showAddOption |
Type : unknown
|
Default value : signal(false)
|
|
whether the "add new" option is logically allowed in the current context (e.g. not creating a duplicate) |
| templateRef |
Type : unknown
|
Default value : contentChild(TemplateRef)
|
| trackByOptionValueFn |
Type : TrackByFunction<SelectableOption<O, V>> | undefined
|
Default value : () => {...}
|
| virtualScrollViewport |
Type : unknown
|
Default value : viewChild(CdkVirtualScrollViewport)
|
| controlType |
Type : string
|
Default value : "custom-control"
|
|
Inherited from
CustomFormControlDirective
|
|
Defined in
CustomFormControlDirective:114
|
| elementRef |
Type : unknown
|
Default value : inject<ElementRef<HTMLElement>>(ElementRef)
|
|
Inherited from
CustomFormControlDirective
|
|
Defined in
CustomFormControlDirective:87
|
| Readonly enabled |
Type : Signal<boolean>
|
Default value : computed(() => !this._disabled())
|
|
Inherited from
CustomFormControlDirective
|
|
Defined in
CustomFormControlDirective:80
|
|
Whether the control is currently enabled, as a signal (tracks disabled). |
| errorStateMatcher |
Type : unknown
|
Default value : inject(ErrorStateMatcher)
|
|
Inherited from
CustomFormControlDirective
|
|
Defined in
CustomFormControlDirective:88
|
| id |
Type : unknown
|
Default value : `custom-form-control-${CustomFormControlDirective.nextId++}`
|
|
Inherited from
CustomFormControlDirective
|
|
Defined in
CustomFormControlDirective:96
|
| Static nextId |
Type : number
|
Default value : 0
|
|
Inherited from
CustomFormControlDirective
|
|
Defined in
CustomFormControlDirective:95
|
| onChange |
Type : unknown
|
Default value : () => {...}
|
|
Inherited from
CustomFormControlDirective
|
|
Defined in
CustomFormControlDirective:115
|
| onTouched |
Type : unknown
|
Default value : () => {...}
|
|
Inherited from
CustomFormControlDirective
|
|
Defined in
CustomFormControlDirective:116
|
| parentForm |
Type : unknown
|
Default value : inject(NgForm, { optional: true })
|
|
Inherited from
CustomFormControlDirective
|
|
Defined in
CustomFormControlDirective:92
|
| parentFormGroup |
Type : unknown
|
Default value : inject(FormGroupDirective, { optional: true })
|
|
Inherited from
CustomFormControlDirective
|
|
Defined in
CustomFormControlDirective:93
|
| stateChanges |
Type : unknown
|
Default value : new Subject<void>()
|
|
Inherited from
CustomFormControlDirective
|
|
Defined in
CustomFormControlDirective:113
|
| Readonly valueSignal |
Type : Signal<T>
|
Default value : computed(() => this._value())
|
|
Inherited from
CustomFormControlDirective
|
|
Defined in
CustomFormControlDirective:77
|
|
The current value of the control as a signal.
Authoritative in both modes: it reflects the bound |
| displayText |
getdisplayText()
|
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
contentChild,
DestroyRef,
effect,
ElementRef,
input,
inject,
OnInit,
output,
signal,
Signal,
TemplateRef,
TrackByFunction,
untracked,
viewChild,
WritableSignal,
} from "@angular/core";
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
import { NgTemplateOutlet } from "@angular/common";
import {
MAT_FORM_FIELD,
MatFormFieldControl,
} from "@angular/material/form-field";
import { FormControl, ReactiveFormsModule } from "@angular/forms";
import { MatInput, MatInputModule } from "@angular/material/input";
import {
MatAutocompleteModule,
MatAutocompleteTrigger,
} from "@angular/material/autocomplete";
import { MatCheckboxModule } from "@angular/material/checkbox";
import { auditTime, filter, map, startWith } from "rxjs/operators";
import { CustomFormControlDirective } from "./custom-form-control.directive";
import {
MatChipGrid,
MatChipInput,
MatChipRemove,
MatChipRow,
} from "@angular/material/chips";
import { MatTooltip } from "@angular/material/tooltip";
import {
CdkDragDrop,
DragDropModule,
moveItemInArray,
} from "@angular/cdk/drag-drop";
import {
CdkFixedSizeVirtualScroll,
CdkVirtualForOf,
CdkVirtualScrollViewport,
ViewportRuler,
} from "@angular/cdk/scrolling";
import { EMPTY, fromEvent, merge } from "rxjs";
import { FaDynamicIconComponent } from "../fa-dynamic-icon/fa-dynamic-icon.component";
// re-export FaDynamicIconComponent to avoid forcing users of BasicAutocompleteComponent to import separately
export { FaDynamicIconComponent } from "../fa-dynamic-icon/fa-dynamic-icon.component";
/**
* Configuration for a single "Add new [label]" entry in the autocomplete dropdown.
* Pass an array of these via `[createOptions]` to show one create option per entity type.
*/
export interface CreateOptionConfig<O> {
/** Label shown in the dropdown, e.g. the entity type's human-readable name */
label: string;
/** Called when the user selects this option; should open a creation form and return the new entity */
create: (input: string) => Promise<O>;
}
interface CreateOptionMarker<O> {
__createOptionConfig: CreateOptionConfig<O>;
__input: string;
}
interface SelectableOption<O, V> {
initial: O;
asString: string;
asValue: V;
selected: boolean;
isHidden: boolean;
isInvalid?: boolean;
isEmpty?: boolean;
}
export const BASIC_AUTOCOMPLETE_COMPONENT_IMPORTS = [
ReactiveFormsModule,
MatInputModule,
MatAutocompleteModule,
MatCheckboxModule,
NgTemplateOutlet,
MatChipInput,
MatChipGrid,
MatChipRow,
FaDynamicIconComponent,
MatTooltip,
MatChipRemove,
DragDropModule,
CdkVirtualScrollViewport,
CdkVirtualForOf,
CdkFixedSizeVirtualScroll,
];
/**
* Custom `MatFormFieldControl` for any select / dropdown field.
*/
@Component({
selector: "app-basic-autocomplete",
templateUrl: "basic-autocomplete.component.html",
styleUrls: ["./basic-autocomplete.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{ provide: MatFormFieldControl, useExisting: BasicAutocompleteComponent },
{
provide: MAT_FORM_FIELD,
useFactory: () =>
inject(MAT_FORM_FIELD, { optional: true, skipSelf: true }),
},
],
imports: BASIC_AUTOCOMPLETE_COMPONENT_IMPORTS,
})
export class BasicAutocompleteComponent<O, V = O>
extends CustomFormControlDirective<V | V[]>
implements OnInit, AfterViewInit
{
private readonly cdr = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly viewportRuler = inject(ViewportRuler);
templateRef = contentChild(TemplateRef);
// Query the search `<input #inputElement>` by its template-ref name (read as MatInput)
// rather than by type: a bare `viewChild(MatInput)` would match the first MatInput in
// the view (the display input inside the `@if`), whereas the old `@ViewChild(MatInput,
// { static: true })` skipped structural content and resolved the search input.
// `_elementRef` is protected in `MatInput`, so widen the read type to expose it.
inputElement = viewChild("inputElement", { read: MatInput }) as Signal<
(MatInput & { _elementRef: ElementRef<HTMLElement> }) | undefined
>;
autocomplete = viewChild(MatAutocompleteTrigger);
virtualScrollViewport = viewChild(CdkVirtualScrollViewport);
valueMapper = input<(option: O) => V>(
(option: O) => option?.["_id"] ?? (option as unknown as V),
);
optionToString = input<(option: O) => string>(
(option: O) => option?.["_label"] ?? option?.toString(),
);
/** @deprecated Prefer `createOptions` to support one unified create flow. */
createOption = input<(input: string) => Promise<O>>();
createOptions = input<CreateOptionConfig<O>[]>([]);
hideOption = input<(option: O) => boolean>(() => false);
/**
* Used in template to display the "Add new" option label.
* Delegates to optionToString so callers with a custom optionToString (e.g. showing
* an example date) get a preview in the "Add new" option too.
* Falls back to the raw input when optionToString throws or returns null/undefined
* (e.g. when O is an object type whose properties don't exist on a plain string).
*/
protected createOptionDisplay(input: string): string {
try {
return this.optionToString()(input as unknown as O) ?? input;
} catch {
return input;
}
}
protected availableCreateOptions = computed<CreateOptionConfig<O>[]>(() => {
if (this.createOptions().length > 0) {
return this.createOptions();
}
if (!this.createOption()) {
return [];
}
return [{ label: "", create: this.createOption() }];
});
protected createOptionLabel(
option: CreateOptionConfig<O>,
input: string,
): string {
return option.label || this.createOptionDisplay(input);
}
protected createOptionAriaLabel(
option: CreateOptionConfig<O>,
input: string,
): string {
return $localize`:ARIA label for adding an option in a dropdown:Add new ${this.createOptionLabel(
option,
input,
)}`;
}
/**
* Whether the user should be able to select multiple values.
*/
multi = input<boolean>();
/**
* Whether the user can manually drag & drop to reorder the selected items
*/
reorder = input<boolean>();
autocompleteForm = new FormControl("");
/**
* Keep the inner autocomplete input's enabled state in sync with the control,
* reacting to the base `enabled` signal instead of overriding the `disabled` setter.
*/
private readonly _syncAutocompleteFormEnabled = effect(() => {
this.enabled()
? this.autocompleteForm.enable()
: this.autocompleteForm.disable();
});
autocompleteSuggestedOptions = this.autocompleteForm.valueChanges.pipe(
filter((val) => typeof val === "string"),
map((val) => this.updateAutocomplete(val)),
startWith([] as SelectableOption<O, V>[]),
);
autocompleteOptions: Signal<SelectableOption<O, V>[]> = toSignal(
this.autocompleteSuggestedOptions,
{ initialValue: [] as SelectableOption<O, V>[] },
);
autocompleteFilterFunction: (option: O) => boolean;
autocompleteFilterChange = output<(o: O) => boolean>();
/** whether the "add new" option is logically allowed in the current context (e.g. not creating a duplicate) */
showAddOption = signal(false);
/**
* Dynamic width of the autocomplete dropdown panel.
* Set to match the full width of the Material form field container (including icons/padding).
*/
panelWidth = signal("200px");
/**
* Maximum number of options to display in the dropdown.
* If more options match the current filter, a hint is shown to type to narrow results.
* Set to 0 for no limit. Defaults to 100.
*/
maxOptionsToDisplay = input<number>(100);
hasMoreOptions = signal(false);
/**
* Whether dropdown option labels should be shown in full length.
* Set to false to truncate labels with ellipsis.
*/
displayFullLengthOptionLabel = input(false);
// Kept as a getter (not a `computed`): it reads `_options`, a plain mutable array
// (rebuilt/reordered/pushed imperatively), so a `computed` would cache stale results
// unless `_options` were made a signal — a larger refactor not worth it here.
get displayText() {
const values: V[] = Array.isArray(this.value) ? this.value : [this.value];
return values
.map(
(v) =>
this._options.find((o) => this.compareEnumValues(o.asValue, v))
?.asString,
)
.join(", ");
}
/**
* The options to display in the autocomplete dropdown.
* If you pass complex objects here, you can customize what value is displayed and what value is output/stored
* by overriding the `valueMapper` and `optionToString` methods via inputs.
* By default, the "_id" property is used as the value and the "_label" property or `toString()` method as the display value.
*
* @param options Array of available options (can be filtered further by the `hideOption` function)
*/
options = input<O[]>([]);
/**
* The source of options the dropdown displays. Defaults to the `options` input;
* subclasses can override this to derive options from an internal computed instead
* (replacing the former pattern of imperatively assigning `this.options`).
*/
protected optionsSource = computed<O[]>(() => this.options());
private _options: SelectableOption<O, V>[] = [];
_selectedOptions = signal<SelectableOption<O, V>[]>([]);
/**
* Keep the search value to help users quickly multi-select multiple related options without having to type filter text again
*/
retainSearchValue: string;
/**
* Display the selected items as simple text, as chips or not at all (if used in combination with another component)
*/
display = input<"text" | "chips" | "none">("text");
/**
* Derived display mode. Reorderable multi-select uses chips without mutating the public input.
*/
effectiveDisplay = computed<"text" | "chips" | "none">(() =>
this.multi() && this.reorder() && this.display() === "text"
? "chips"
: this.display(),
);
/**
* display the search input rather than the selected elements only
* (when the form field gets focused).
*/
isInSearchMode: WritableSignal<boolean> = signal(false);
trackByOptionValueFn: TrackByFunction<SelectableOption<O, V>> | undefined = (
i,
o,
) => o?.asValue;
/**
* Height of the virtual scroll viewport, capped to fit within the panel.
* Material's default panel max-height is 256px. We reserve space for footer elements
* ("type to filter" hint, "show inactive" toggle, padding) and cap the viewport accordingly
* so that virtual scrolling actually virtualizes (instead of rendering all items).
*/
private static readonly PANEL_MAX_HEIGHT = 256;
private static readonly FOOTER_RESERVE = 56;
viewportHeight = computed(() => {
const contentHeight = (this.autocompleteOptions()?.length ?? 0) * 48;
const availableHeight =
BasicAutocompleteComponent.PANEL_MAX_HEIGHT -
BasicAutocompleteComponent.FOOTER_RESERVE;
return Math.min(contentHeight, availableHeight);
});
constructor() {
super();
// Rebuild the derived options whenever the `options` input (or the
// value/label mappers) change. Replaces the former `set options` setter
// and the `valueMapper`/`optionToString`/`options` `ngOnChanges` branches.
effect(() => {
const options = this.optionsSource();
// read the mapper inputs so the rebuild also reacts to their changes
this.valueMapper();
this.optionToString();
// The block below is `untracked` so the effect only re-runs for the
// dependencies read above (options + mappers). Without it the effect
// would also track the signals read inside `setInitialInputValue`
// (e.g. the control `value`) and re-run — and rebuild the derived
// options — on every value change, which is both wasteful and would
// discard runtime-created options.
untracked(() => {
this._options = options.map((o) => this.toSelectableOption(o));
this.setInitialInputValue();
if (this.autocomplete()?.panelOpen) {
// if new options have been added, update the visible autocomplete options
this.showAutocomplete(this.autocompleteForm.value);
}
});
});
// Re-check the virtual scroll viewport whenever the visible options change
// (replaces the setTimeout that ran inside the former options subscription).
effect(() => {
this.autocompleteOptions();
setTimeout(() => this.virtualScrollViewport()?.checkViewportSize());
});
}
ngOnInit() {
// Subscribe to the valueChanges observable to print the input value
this.autocompleteForm.valueChanges.subscribe((value) => {
if (
typeof value === "string" &&
(this.display() !== "text" || value !== this.displayText)
) {
this.retainSearchValue = value;
}
});
}
ngAfterViewInit() {
merge(
fromEvent(window, "focus"),
fromEvent(window, "resize"),
this.viewportRuler.change(),
this.getVisualViewportChangeEvents(),
)
.pipe(auditTime(16), takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.updateOpenPanelPosition());
}
/**
* Set the width of the dropdown panel programmatically to match the parent form field.
* (this is not possible with pure CSS)
*
* Note: If the field is close to the viewport edge, Angular Material's overlay system may shift the dropdown horizontally
* to keep it visible, causing minor misalignment. This is expected and ensures accessibility.
*/
public updatePanelWidth() {
// Use closest .mat-mdc-form-field or .mat-form-field from input element
const fieldEl = this.inputElement()?._elementRef?.nativeElement.closest(
".mat-mdc-form-field, .mat-form-field",
) as HTMLElement;
const fieldWidth = fieldEl ? fieldEl.getBoundingClientRect().width : 200;
this.panelWidth.set(`${fieldWidth}px`);
}
/**
* Returns viewport-change events that are not always covered by window resize
* (e.g. DevTools docking/undocking and some browser UI changes).
*/
private getVisualViewportChangeEvents() {
const visualViewport = window.visualViewport;
if (!visualViewport) {
return EMPTY;
}
return merge(
fromEvent(visualViewport, "resize"),
fromEvent(visualViewport, "scroll"),
);
}
/**
* Recomputes width and position of an open autocomplete panel.
* Runs twice (immediately and on next animation frame) to handle late layout updates.
*/
private updateOpenPanelPosition(): void {
if (!this.autocomplete()?.panelOpen) {
return;
}
this.updatePanelWidth();
this.autocomplete()?.updatePosition();
requestAnimationFrame(() => {
if (!this.autocomplete()?.panelOpen) {
return;
}
this.updatePanelWidth();
this.autocomplete()?.updatePosition();
});
}
dropChips(event: CdkDragDrop<any[]>) {
if (event.previousContainer !== event.container) {
return;
}
const selectedOptions = [...this._selectedOptions()];
moveItemInArray(selectedOptions, event.previousIndex, event.currentIndex);
this._options = this.orderSelectedFirst(this._options, selectedOptions);
if (this.multi()) {
this.value = selectedOptions.map((option) => option.asValue);
} else {
this.value = undefined;
}
this.setInitialInputValue();
this.onChange(this.value);
this.showAutocomplete(this.autocompleteForm.value);
}
showAutocomplete(valueToRevertTo?: string) {
if (this.multi() && this.retainSearchValue) {
// reset the search value to previously entered text to help user selecting multiple similar options without retyping filter text
this.autocompleteForm.setValue(this.retainSearchValue);
} else if (
this.multi() &&
this.effectiveDisplay() === "text" &&
this.displayText
) {
// keep selected items visible when the multi-select input is focused/opened
this.autocompleteForm.setValue(this.displayText);
} else {
// reset the search value to show all available options again
this.autocompleteForm.setValue("");
}
if (!this.multi()) {
// cannot setValue to "" here because the current selection would be lost
this.autocompleteForm.setValue(this.displayText, { emitEvent: false });
}
// Update panel width when autocomplete is actually shown (when form field is rendered)
this.updatePanelWidth();
setTimeout(() => {
const inputEl = this.inputElement();
inputEl?.focus();
// select all text for easy overwriting when typing to search for options
(inputEl?._elementRef.nativeElement as HTMLInputElement)?.select();
if (valueToRevertTo) {
this.autocompleteForm.setValue(valueToRevertTo);
}
});
this.isInSearchMode.set(true);
// update virtual scroll as the container remains empty until the user scrolls initially
setTimeout(() => this.virtualScrollViewport()?.checkViewportSize());
}
private updateAutocomplete(inputText: string): SelectableOption<O, V>[] {
const filterText =
this.multi() &&
this.effectiveDisplay() === "text" &&
inputText === this.displayText
? ""
: inputText;
let filteredOptions = this._options.filter(
(o) => !this.hideOption()(o.initial) && !o.isHidden,
);
if (filterText) {
this.autocompleteFilterFunction = (option) =>
this.optionToString()(option)
?.toLowerCase()
?.includes(filterText.toLowerCase());
this.autocompleteFilterChange.emit(this.autocompleteFilterFunction);
filteredOptions = filteredOptions.filter((o) =>
this.autocompleteFilterFunction(o.initial),
);
// do not allow users to create a new entry "identical" to an existing one:
this.showAddOption.set(
!this._options.some(
(o) => o?.asString?.toLowerCase() === filterText?.toLowerCase(),
),
);
}
if (
this.maxOptionsToDisplay() > 0 &&
filteredOptions.length > this.maxOptionsToDisplay()
) {
this.hasMoreOptions.set(true);
filteredOptions = filteredOptions.slice(0, this.maxOptionsToDisplay());
} else {
this.hasMoreOptions.set(false);
}
if (this.multi() && this.reorder()) {
filteredOptions = this.orderSelectedFirst(
filteredOptions,
this._selectedOptions(),
);
}
return filteredOptions;
}
private orderSelectedFirst(
options: SelectableOption<O, V>[],
selected: SelectableOption<O, V>[],
): SelectableOption<O, V>[] {
const selectedSet = new Set(selected);
return [
...selected.filter((option) => options.includes(option)),
...options.filter((option) => !selectedSet.has(option)),
];
}
/**
* Compare two enum values by id if present, otherwise by reference.
*/
compareEnumValues(a: any, b: any): boolean {
if (a === b) return true;
if (!a || !b) return false;
if (a.id !== undefined && b.id !== undefined) {
return a.id === b.id;
}
if (a.value !== undefined && b.value !== undefined) {
return a.value === b.value;
}
return false;
}
private setInitialInputValue() {
this._options.forEach(
(o) =>
(o.selected = Array.isArray(this.value)
? this.value?.some((v) => this.compareEnumValues(v, o.asValue))
: this.compareEnumValues(this.value, o.asValue)),
);
this._selectedOptions.set(
this._options.filter((o) => o.selected && !o.isHidden),
);
}
select(selected: string | SelectableOption<O, V> | CreateOptionMarker<O>) {
if (
selected != null &&
typeof selected === "object" &&
"__createOptionConfig" in selected
) {
this.createFromConfig(selected as CreateOptionMarker<O>);
return;
}
if (typeof selected === "string") {
const defaultCreateOption = this.availableCreateOptions()[0];
if (defaultCreateOption) {
this.createFromConfig(
this.toCreateOptionValue(defaultCreateOption, selected),
);
}
return;
}
if (selected) {
this.selectOption(selected as SelectableOption<O, V>);
} else {
this.autocompleteForm.setValue("");
this._selectedOptions.set([]);
this.value = undefined;
}
this.onChange(this.value);
}
unselect(option: SelectableOption<O, V>) {
option.selected = false;
this._selectedOptions.set(this._options.filter((o) => o.selected));
if (this.multi()) {
this.value = this._selectedOptions().map((o) => o.asValue);
} else {
this.value = undefined;
}
this.onChange(this.value);
}
/** @internal used in template to build a marker value for typed create options */
protected toCreateOptionValue(
option: CreateOptionConfig<O>,
input: string,
): CreateOptionMarker<O> {
return { __createOptionConfig: option, __input: input };
}
async createFromConfig(marker: CreateOptionMarker<O>) {
const createdOption = await marker.__createOptionConfig.create(
marker.__input,
);
if (createdOption) {
const newOption = this.toSelectableOption(createdOption);
this._options.push(newOption);
this.select(newOption);
} else {
this.showAutocomplete();
this.autocompleteForm.setValue(marker.__input);
}
}
private selectOption(option: SelectableOption<O, V>) {
if (this.multi()) {
option.selected = !option.selected;
this._selectedOptions.set(this._options.filter((o) => o.selected));
this.value = this._selectedOptions().map((o) => o.asValue);
// re-open autocomplete to select next option
setTimeout(() => this.showAutocomplete());
} else {
this._selectedOptions.set([option]);
this.value = option.asValue;
this.isInSearchMode.set(false);
}
}
private toSelectableOption(opt: O): SelectableOption<O, V> {
return {
initial: opt,
asValue: this.valueMapper()(opt),
asString: this.optionToString()(opt),
selected: false,
isHidden: (opt as SelectableOption<O, V>)?.isHidden ?? false,
isInvalid: (opt as SelectableOption<O, V>)?.isInvalid ?? false,
isEmpty: (opt as SelectableOption<O, V>)?.isEmpty ?? false,
};
}
onFocusOut(event: FocusEvent) {
if (
!this.elementRef.nativeElement.contains(event.relatedTarget as Element)
) {
if (!this.multi() && this.autocompleteForm.value === "") {
this.select(undefined);
}
this.isInSearchMode.set(false);
this.retainSearchValue = "";
}
}
override onContainerClick(event: MouseEvent) {
const target = event.target;
const clickedOption =
target instanceof Element
? target.closest(".mat-mdc-option, .mat-option")
: null;
if (!this.disabled && !clickedOption) {
this.showAutocomplete();
}
}
override writeValue(val: V[] | V, notifyFormControl = false): void {
super.writeValue(val, notifyFormControl);
this.setInitialInputValue();
this.cdr.markForCheck();
}
}
<div matAutocompleteOrigin #autocompleteOrigin="matAutocompleteOrigin">
<!--Display-->
@if (effectiveDisplay() === "text" || effectiveDisplay() === "none") {
<input
[id]="id"
[hidden]="isInSearchMode() || effectiveDisplay() === 'none'"
[disabled]="!enabled()"
matInput
style="text-overflow: ellipsis; width: calc(100% - 50px)"
(focusin)="showAutocomplete()"
(focusout)="showAutocomplete()"
[value]="displayText"
[placeholder]="placeholder"
/>
} @else {
<input
[hidden]="true"
[disabled]="!enabled()"
matInput
(focusin)="showAutocomplete()"
(focusout)="showAutocomplete()"
[matChipInputFor]="chipList"
/>
<mat-chip-grid
#chipList
cdkDropList
cdkDropListOrientation="mixed"
[cdkDropListDisabled]="!reorder()"
(cdkDropListDropped)="dropChips($event)"
class="chip-drop-list"
>
@for (item of _selectedOptions(); track item.asValue) {
<mat-chip-row
cdkDrag
[cdkDragDisabled]="!reorder()"
[editable]="enabled()"
class="chip"
[style.background-color]="item.asValue?.['color']"
>
<ng-template
[ngTemplateOutlet]="chipContent"
[ngTemplateOutletContext]="{ $implicit: item }"
></ng-template>
@if (enabled()) {
<button matChipRemove (click)="unselect(item)">
<app-fa-dynamic-icon
i18n-matTooltip="
tooltip for remove icon on chips of dropdown item
"
matTooltip="remove"
icon="xmark"
></app-fa-dynamic-icon>
</button>
}
<ng-template cdkDragPreview>
<mat-chip-row
[editable]="false"
class="chip chip-preview"
[style.background-color]="item.asValue?.['color']"
>
<ng-template
[ngTemplateOutlet]="chipContent"
[ngTemplateOutletContext]="{ $implicit: item }"
></ng-template>
</mat-chip-row>
</ng-template>
</mat-chip-row>
}
</mat-chip-grid>
}
<!--Search-->
<input
[hidden]="!isInSearchMode()"
#inputElement
[formControl]="autocompleteForm"
matInput
style="text-overflow: ellipsis"
[matAutocomplete]="autoSuggestions"
[matAutocompleteConnectedTo]="autocompleteOrigin"
(focusout)="onFocusOut($event)"
[placeholder]="placeholder"
/>
</div>
<!--
Autocomplete
-->
<mat-autocomplete
[disableRipple]="true"
#autoSuggestions="matAutocomplete"
(optionSelected)="select($event.option.value)"
autoActiveFirstOption
[hideSingleSelectionIndicator]="true"
[panelWidth]="panelWidth()"
>
<!-- Select All and Clear Buttons on top of options via content projection-->
<div>
<ng-content select="[autocompleteHeader]"></ng-content>
</div>
<div>
@if (!displayFullLengthOptionLabel()) {
<cdk-virtual-scroll-viewport
[style.height.px]="viewportHeight()"
[itemSize]="48"
minBufferPx="200"
maxBufferPx="500"
>
<mat-option
[value]="item"
*cdkVirtualFor="
let item of autocompleteOptions();
trackBy: trackByOptionValueFn
"
>
<ng-container
[ngTemplateOutlet]="optionContent"
[ngTemplateOutletContext]="{ $implicit: item, truncate: true }"
></ng-container>
</mat-option>
</cdk-virtual-scroll-viewport>
} @else {
@for (item of autocompleteOptions(); track item.asValue) {
<mat-option [value]="item" class="option-with-wrap">
<ng-container
[ngTemplateOutlet]="optionContent"
[ngTemplateOutletContext]="{ $implicit: item, truncate: false }"
></ng-container>
</mat-option>
}
}
</div>
<ng-template #chipContent let-item>
@if (reorder()) {
<span class="drag-handle color-accent" cdkDragHandle>
<app-fa-dynamic-icon icon="grip-vertical"></app-fa-dynamic-icon>
</span>
}
@if (!templateRef()) {
{{ item.asString }}
} @else {
<ng-template
[ngTemplateOutlet]="templateRef()"
[ngTemplateOutletContext]="{ $implicit: item.initial }"
></ng-template>
}
</ng-template>
<ng-template #optionContent let-item let-truncate="truncate">
<div
class="option-content flex-row disable-autocomplete-active-color align-center"
>
@if (multi()) {
<mat-checkbox [checked]="item.selected"></mat-checkbox>
}
@if (!templateRef()) {
<span
class="option-label"
[class.text-truncate]="truncate"
[class.text-wrap]="!truncate"
[matTooltip]="item.asString"
matTooltipPosition="above"
[class.not-defined-label]="item.isEmpty"
[class.invalid-label]="item.isInvalid"
>
{{ item.asString }}
</span>
} @else {
<div class="option-label item-option">
<ng-template
[ngTemplateOutlet]="templateRef()"
[ngTemplateOutletContext]="{ $implicit: item.initial }"
></ng-template>
</div>
}
</div>
</ng-template>
<!-- Create new option -->
@if (showAddOption() && inputElement.value) {
@for (option of availableCreateOptions(); track $index) {
<mat-option
[value]="toCreateOptionValue(option, inputElement.value)"
[attr.aria-label]="createOptionAriaLabel(option, inputElement.value)"
>
<em
i18n="
Label for adding an option in a dropdown|e.g. Add new My new Option
"
>Add new</em
>
{{ createOptionLabel(option, inputElement.value) }}
</mat-option>
}
}
<mat-option style="display: none">
<!-- This mat-option is never displayed ("display: none") but has to be there,
because the footer below will only be displayed with at least one mat-option -->
</mat-option>
<div class="autocomplete-footer">
@if (hasMoreOptions()) {
<em
class="more-options-hint"
i18n="Hint shown when dropdown has more options than displayed"
>Type to filter more results ...</em
>
}
<ng-content select="[autocompleteFooter]"></ng-content>
</div>
</mat-autocomplete>
./basic-autocomplete.component.scss
@use "variables/colors";
@use "variables/sizes";
@use "../special-option-labels.scss";
:host {
overflow: hidden;
}
em {
color: colors.$primary;
}
.disable-autocomplete-active-color {
color: black;
}
.autocomplete-footer {
margin: sizes.$small sizes.$regular;
}
.more-options-hint {
display: block;
font-size: 0.85em;
color: colors.$muted;
padding: sizes.$small 0;
}
::ng-deep {
.cdk-virtual-scroll-orientation-vertical .cdk-virtual-scroll-content-wrapper {
width: 100%;
}
.cdk-drag-preview {
box-sizing: border-box;
border-radius: 4px;
}
.cdk-drag-placeholder {
opacity: 0;
}
.cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
}
.text-truncate {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.option-content {
width: 100%;
min-width: 0;
justify-content: flex-start;
}
.option-label {
display: block;
flex: 1 1 auto;
min-width: 0;
}
.text-wrap {
white-space: normal;
overflow: visible;
text-overflow: clip;
line-height: 1.3;
}
.chip {
border-radius: 4px;
}
.chip-drop-list.cdk-drop-list-dragging .chip:not(.cdk-drag-placeholder) {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.chip-preview {
box-shadow:
0 5px 5px -3px rgba(0, 0, 0, 0.2),
0 8px 10px 1px rgba(0, 0, 0, 0.14),
0 3px 14px 2px rgba(0, 0, 0, 0.12);
cursor: grabbing;
}
.drag-handle {
display: inline-flex;
align-items: center;
margin-right: 8px;
cursor: move;
opacity: 0.5;
}
.drag-handle:hover {
opacity: 1;
}
// Fix for double scrollbars when field/option labels are long
// ensure all options have consistent 48px height for virtual scroll calculations
::ng-deep .mat-mdc-autocomplete-panel .mat-mdc-option {
.mdc-list-item__primary-text {
text-align: left;
}
app-display-configurable-enum {
display: block;
flex: 1 1 auto;
min-width: 0;
}
app-display-configurable-enum .bubble-list {
width: 100%;
min-width: 0;
}
app-display-configurable-enum .colored-bubble {
display: block;
max-width: 100%;
}
&.option-with-wrap app-display-configurable-enum .colored-bubble {
white-space: normal;
overflow: visible;
text-overflow: clip;
overflow-wrap: break-word;
}
}
::ng-deep .mat-mdc-autocomplete-panel .mat-mdc-option:not(.option-with-wrap) {
max-height: 48px;
.mdc-list-item__primary-text {
display: block;
width: 100%;
overflow: visible;
}
}
::ng-deep .mat-mdc-autocomplete-panel .mat-mdc-option.option-with-wrap {
height: auto;
min-height: 48px;
max-height: none;
.mdc-list-item__primary-text {
white-space: normal;
}
}