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) {
@if (o.swatch) { } {{ o.label }} @if (isSel(o)) { }
}
`, }) export class VspComboList implements AfterViewInit { @Input() q = ''; @Input() items: SelectOption[] = []; @Input() activeIdx = 0; @Input() isSel: (o: SelectOption) => boolean = () => false; @Input() searchPlaceholder?: string; @Input() loading = false; @Input() emptyText?: string; @Output() qChange = new EventEmitter(); @Output() activeIdxChange = new EventEmitter(); @Output() pick = new EventEmitter(); @ViewChild('search') search?: ElementRef; ngAfterViewInit(): void { setTimeout(() => this.search?.nativeElement.focus(), 30); } onInput(e: Event): void { this.qChange.emit((e.target as HTMLInputElement).value); this.activeIdxChange.emit(0); } onKey(e: KeyboardEvent): void { if (e.key === 'ArrowDown') { e.preventDefault(); this.activeIdxChange.emit(Math.min(this.items.length - 1, this.activeIdx + 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); this.activeIdxChange.emit(Math.max(0, this.activeIdx - 1)); } else if (e.key === 'Enter') { e.preventDefault(); const it = this.items[this.activeIdx]; if (it) this.pick.emit(it); } } } /* ---------------- Select (themed drop-in, auto-searches at ≥8 options) ---------------- */ @Component({ selector: 'vsp-select', imports: [VspSelPanel, VspComboList], template: ` @if (name) { } @if (open) { @if (useSearch) { } @else {
@if (items.length === 0) {
{{ emptyText ?? 'No options' }}
} @for (o of items; track o.value) {
@if (o.swatch) { } {{ o.label }} @if (sameVal(o)) { }
}
} }
`, }) export class VspSelect { @Input() options: SelectOptionInput[] = []; @Input() value?: SelectValue; @Output() valueChange = new EventEmitter(); @Input() placeholder = 'Select…'; @Input() disabled = false; @Input() invalid = false; @Input() emptyText?: string; @Input() id?: string; @Input() name?: string; @Input() searchable?: boolean; open = false; q = ''; active = 0; get opts(): SelectOption[] { return this.options.map(normalize); } get cur(): SelectValue | undefined { return this.value ?? this.opts[0]?.value; } get sel(): SelectOption | undefined { return this.opts.find((o) => String(o.value) === String(this.cur)); } get useSearch(): boolean { return this.searchable ?? this.opts.length >= 8; } get items(): SelectOption[] { return this.useSearch ? this.opts.filter((o) => o.label.toLowerCase().includes(this.q.toLowerCase())) : this.opts; } isSelFn = (o: SelectOption): boolean => String(o.value) === String(this.cur); sameVal(o: SelectOption): boolean { return String(o.value) === String(this.cur); } choose(o: SelectOption): void { this.value = o.value; this.valueChange.emit(o.value); this.open = false; this.q = ''; } } /* ---------------- Combobox (searchable single select) ---------------- */ @Component({ selector: 'vsp-combobox', imports: [VspSelPanel, VspComboList], template: ` @if (name) { } @if (open) { @if (canCreate) { } } `, }) export class VspCombobox { @Input() options: SelectOptionInput[] = []; @Input() value: SelectValue | null = null; @Output() valueChange = new EventEmitter(); @Input() placeholder = 'Select…'; @Input() searchPlaceholder?: string; @Input() clearable = false; @Input() disabled = false; @Input() invalid = false; @Input() loading = false; @Input() emptyText?: string; @Input() id?: string; @Input() name?: string; @Input() creatable = false; @Output() created = new EventEmitter(); open = false; q = ''; active = 0; get canCreate(): boolean { const t = this.q.trim(); return ( this.creatable && !!t && !this.opts.some((o) => o.label.toLowerCase() === t.toLowerCase()) ); } create(): void { this.created.emit(this.q.trim()); this.open = false; this.q = ''; } get opts(): SelectOption[] { return this.options.map(normalize); } get sel(): SelectOption | undefined { return this.opts.find((o) => o.value === this.value); } get items(): SelectOption[] { return this.opts.filter((o) => o.label.toLowerCase().includes(this.q.toLowerCase())); } isSelFn = (o: SelectOption): boolean => o.value === this.value; set(v: SelectValue | null): void { this.value = v; this.valueChange.emit(v); } clear(e: Event): void { e.stopPropagation(); this.set(null); } pick(o: SelectOption): void { this.set(o.value); this.open = false; this.q = ''; } } /* ---------------- MultiSelect (searchable, tagged) ---------------- */ @Component({ selector: 'vsp-multi-select', imports: [VspSelPanel, VspComboList], template: `
@if (selOpts.length === 0) { {{ placeholder }} } @else { @for (o of selOpts; track o.value) { {{ o.label }} } }
@if (name) { @for (v of value; track v) { } } @if (open) {
{{ value.length }} selected{{ max ? ' / ' + max : '' }}
@if (value.length > 0) { }
}
`, }) export class VspMultiSelect { @Input() options: SelectOptionInput[] = []; @Input() value: SelectValue[] = []; @Output() valueChange = new EventEmitter(); @Input() placeholder = 'Select…'; @Input() searchPlaceholder?: string; @Input() max?: number; @Input() disabled = false; @Input() invalid = false; @Input() loading = false; @Input() emptyText?: string; @Input() id?: string; @Input() name?: string; open = false; q = ''; active = 0; get opts(): SelectOption[] { return this.options.map(normalize); } get items(): SelectOption[] { return this.opts.filter((o) => o.label.toLowerCase().includes(this.q.toLowerCase())); } get selOpts(): SelectOption[] { return this.opts.filter((o) => this.has(o.value)); } has(v: SelectValue): boolean { return this.value.includes(v); } isSelFn = (o: SelectOption): boolean => this.has(o.value); set(next: SelectValue[]): void { this.value = next; this.valueChange.emit(next); } toggle(o: SelectOption): void { if (this.has(o.value)) this.set(this.value.filter((v) => v !== o.value)); else if (!this.max || this.value.length < this.max) this.set([...this.value, o.value]); } remove(o: SelectOption): void { this.set(this.value.filter((v) => v !== o.value)); } toggleOpen(e: Event): void { if (this.disabled) return; e.preventDefault(); this.open = !this.open; } } /* ---------------- TokenInput (creatable tags) ---------------- */ @Component({ selector: 'vsp-token-input', template: `
@for (t of value; track t) { {{ t }} }
`, }) export class VspTokenInput { @Input() value: string[] = []; @Output() valueChange = new EventEmitter(); @Input() placeholder = 'Type and press Enter…'; @Input() disabled = false; @Input() invalid = false; @Input() max?: number; @Input() id?: string; @ViewChild('root') root?: ElementRef; draft = ''; get full(): boolean { return this.max != null && this.value.length >= this.max; } set(next: string[]): void { this.value = next; this.valueChange.emit(next); } add(): void { const t = this.draft.trim(); if (t && !this.value.includes(t) && !this.full) this.set([...this.value, t]); this.draft = ''; } remove(t: string): void { this.set(this.value.filter((v) => v !== t)); } focusInput(): void { if (!this.disabled) this.root?.nativeElement.querySelector('input')?.focus(); } onKey(e: KeyboardEvent): void { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); this.add(); } else if (e.key === 'Backspace' && !this.draft && this.value.length) { this.set(this.value.slice(0, -1)); } } }