import {
AfterViewInit,
Component,
ElementRef,
EventEmitter,
Input,
OnChanges,
OnDestroy,
Output,
SimpleChanges,
ViewChild,
} from '@angular/core';
export type SelectValue = string | number;
export interface SelectOption {
value: SelectValue;
label: string;
/** Optional color swatch shown before the label. */
swatch?: string;
}
export type SelectOptionInput = SelectValue | SelectOption;
function normalize(o: SelectOptionInput): SelectOption {
return typeof o === 'object' ? o : { value: o, label: String(o) };
}
/* ---------------- anchored, in-place floating panel ---------------- */
/**
* Floating panel that tracks its trigger. Renders in place — `.ui-menu` is
* positioned `fixed` from the anchor's rect, so it escapes overflow as long as
* the component lives inside `.vsp-root`. Project the panel body as content.
*/
@Component({
selector: 'vsp-sel-panel',
template: `@if (open && rect) {
}`,
})
export class VspSelPanel implements OnChanges, OnDestroy {
@Input() open = false;
@Input() anchor: HTMLElement | null = null;
@Input() width?: number;
@Input() menuClass = 'ui-menu ui-combo';
/** Hug the content (auto width, no padding/max-height) — used by the date pickers. */
@Input() auto = false;
@Output() close = new EventEmitter();
@ViewChild('panel') panel?: ElementRef;
rect: DOMRect | null = null;
private cleanup: (() => void) | null = null;
get maxH(): number {
return Math.max(220, window.innerHeight - (this.rect?.bottom ?? 0) - 16);
}
private place = (): void => {
if (this.anchor) this.rect = this.anchor.getBoundingClientRect();
};
ngOnChanges(c: SimpleChanges): void {
if (c['open']) {
if (this.open) this.activate();
else this.deactivate();
}
}
private activate(): void {
this.place();
const onDoc = (e: MouseEvent): void => {
const t = e.target as Node;
if (!this.panel?.nativeElement.contains(t) && !this.anchor?.contains(t)) this.close.emit();
};
const onEsc = (e: KeyboardEvent): void => {
if (e.key === 'Escape') this.close.emit();
};
document.addEventListener('mousedown', onDoc);
window.addEventListener('keydown', onEsc);
window.addEventListener('resize', this.place);
window.addEventListener('scroll', this.place, true);
this.cleanup = () => {
document.removeEventListener('mousedown', onDoc);
window.removeEventListener('keydown', onEsc);
window.removeEventListener('resize', this.place);
window.removeEventListener('scroll', this.place, true);
};
}
private deactivate(): void {
this.cleanup?.();
this.cleanup = null;
this.rect = null;
}
ngOnDestroy(): void {
this.cleanup?.();
}
}
/* ---------------- search + list ---------------- */
@Component({
selector: 'vsp-combo-list',
template: `
@if (loading) {
Loading…
} @else if (items.length === 0) {
{{ emptyText ?? 'No matches for “' + q + '”' }}
}
@for (o of loading ? [] : items; track o.value; let i = $index) {