import { AfterViewChecked, AfterViewInit, Directive, ElementRef, inject, NgZone, OnDestroy, Renderer2 } from '@angular/core'; /** Keeps native Angular select bindings while providing one consistent, anchored picker. */ @Directive({selector: 'select', standalone: true}) export class DropdownDirective implements AfterViewInit, AfterViewChecked, OnDestroy { private static nextId = 0; private static active?: DropdownDirective; private readonly select = inject>(ElementRef).nativeElement; private readonly renderer = inject(Renderer2); private readonly zone = inject(NgZone); private readonly id = `xs-dropdown-${++DropdownDirective.nextId}`; private trigger?: HTMLButtonElement; private label?: HTMLSpanElement; private menu?: HTMLDivElement; private observer?: MutationObserver; private readonly cleanup: Array<() => void> = []; private menuCleanup: Array<() => void> = []; private pending = false; private destroyed = false; private signature = ''; private search = ''; private searchedAt = 0; private originalTabIndex: string | null = null; private originalAriaHidden: string | null = null; ngAfterViewInit(): void { const select = this.select; // Current app controls are all single-select; don't silently change multi-select semantics. if (select.multiple || select.size > 1) return; const trigger = this.trigger = document.createElement('button'); trigger.type = 'button'; trigger.className = 'xs-dropdown-trigger'; trigger.setAttribute('role', 'combobox'); trigger.setAttribute('aria-haspopup', 'listbox'); trigger.setAttribute('aria-expanded', 'false'); this.label = document.createElement('span'); const arrow = document.createElement('i'); arrow.setAttribute('aria-hidden', 'true'); trigger.append(this.label, arrow); select.insertAdjacentElement('afterend', trigger); this.originalTabIndex = select.getAttribute('tabindex'); this.originalAriaHidden = select.getAttribute('aria-hidden'); select.classList.add('xs-dropdown-native'); select.tabIndex = -1; select.setAttribute('aria-hidden', 'true'); this.listen(trigger, 'click', (event: Event) => { event.preventDefault(); this.menu ? this.close() : this.open(); }); this.listen(trigger, 'keydown', (event: KeyboardEvent) => { if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) { event.preventDefault(); this.open(event.key); } else if (event.key === 'Escape') this.close(); }); this.listen(select, 'focus', () => trigger.focus()); this.listen(select, 'change', () => this.sync()); this.listen(trigger, 'blur', () => select.dispatchEvent(new Event('blur'))); if (select.form) this.listen(select.form, 'reset', () => this.scheduleSync()); const outsideClick = (event: MouseEvent) => { const target = event.target as Node; if (this.menu && !trigger.contains(target) && !this.menu.contains(target) && target !== select) this.close(false); }; const escape = (event: KeyboardEvent) => { if (this.menu && event.key === 'Escape') { event.preventDefault(); event.stopImmediatePropagation(); this.close(); } }; // Capture so modal containers that stop bubbling cannot trap the open menu. document.addEventListener('click', outsideClick, true); document.addEventListener('keydown', escape, true); this.cleanup.push(() => document.removeEventListener('click', outsideClick, true), () => document.removeEventListener('keydown', escape, true)); this.listen(window, 'resize', () => this.positionMenu()); const onScroll = (event: Event) => { if (this.menu && !this.menu.contains(event.target as Node)) this.positionMenu(); }; document.addEventListener('scroll', onScroll, true); this.cleanup.push(() => document.removeEventListener('scroll', onScroll, true)); this.observer = new MutationObserver(() => this.scheduleSync()); this.observer.observe(select, {subtree:true, childList:true, characterData:true, attributes:true, attributeFilter:['disabled','label','value','selected','aria-label','aria-required','required']}); this.sync(); } ngAfterViewChecked(): void { this.scheduleSync(); } private scheduleSync(): void { if (this.pending || this.destroyed || !this.trigger) return; this.pending = true; this.zone.runOutsideAngular(() => queueMicrotask(() => { this.pending = false; if (!this.destroyed) this.sync(); })); } private optionLabel(option: HTMLOptionElement): string { return option.label.trim() || option.textContent?.trim() || '未命名选项'; } private accessibleLabel(): string { const explicit = this.select.getAttribute('aria-label'); if (explicit) return explicit; const label = this.select.labels?.[0]; if (!label) return '选择选项'; const copy = label.cloneNode(true) as HTMLElement; copy.querySelectorAll('select,button,input,textarea,svg').forEach(element => element.remove()); return copy.textContent?.trim() || '选择选项'; } private sync(): void { if (!this.trigger || !this.label) return; this.label.textContent = this.select.selectedOptions[0] ? this.optionLabel(this.select.selectedOptions[0]) : '请选择'; this.trigger.disabled = this.select.disabled; this.trigger.setAttribute('aria-label', this.accessibleLabel()); this.trigger.setAttribute('aria-required', String(this.select.required || this.select.getAttribute('aria-required') === 'true')); const signature = JSON.stringify(Array.from(this.select.options, option => [option.value, this.optionLabel(option), option.disabled, option.parentElement instanceof HTMLOptGroupElement && option.parentElement.disabled, option.selected])); if (this.menu && (signature !== this.signature || this.select.disabled)) this.close(false); this.signature = signature; } private open(key?: string): void { this.sync(); if (!this.trigger || this.trigger.disabled || this.menu) return; DropdownDirective.active?.close(false); // Make room below the control even when it is near the bottom of a dialog or viewport. if (window.innerHeight - this.trigger.getBoundingClientRect().bottom < 150) this.trigger.scrollIntoView({block:'center', behavior:'instant'}); const menu = this.menu = document.createElement('div'); menu.className = 'xs-dropdown-menu'; menu.id = this.id; menu.setAttribute('role', 'listbox'); menu.setAttribute('aria-label', this.accessibleLabel()); for (const option of Array.from(this.select.options)) { if (option.hidden) continue; const button = document.createElement('button'); button.type = 'button'; button.setAttribute('role', 'option'); button.setAttribute('aria-selected', String(option.selected)); button.textContent = this.optionLabel(option); button.disabled = option.disabled || (option.parentElement instanceof HTMLOptGroupElement && option.parentElement.disabled); button.tabIndex = -1; menu.append(button); this.menuCleanup.push(this.renderer.listen(button, 'click', () => { if (this.select.disabled || button.disabled) return; this.select.selectedIndex = option.index; this.close(); this.select.dispatchEvent(new Event('change', {bubbles:true})); this.sync(); })); } if (!menu.children.length) { const empty=document.createElement('p');empty.textContent='暂无可选项';menu.append(empty); } // A viewport overlay escapes scroll cards and modal overflow clipping. document.body.append(menu); this.positionMenu(); this.trigger.setAttribute('aria-expanded', 'true'); this.trigger.setAttribute('aria-controls', this.id); DropdownDirective.active = this; this.menuCleanup.push(this.renderer.listen(menu, 'keydown', (event: KeyboardEvent) => this.menuKeydown(event))); const enabled = this.enabledOptions(); const selected = enabled.find(button => button.getAttribute('aria-selected') === 'true'); const target = key==='Home' ? enabled[0] : key==='End' ? enabled.at(-1) : selected || (key==='ArrowUp' ? enabled.at(-1) : enabled[0]); target?.focus({preventScroll:true}); if (target) menu.scrollTop = Math.max(0, target.offsetTop - menu.clientHeight/2); } private positionMenu(): void { if (!this.menu || !this.trigger) return; const rect = this.trigger.getBoundingClientRect(); if (rect.bottom < 0 || rect.top > window.innerHeight) { this.close(false); return; } const width = Math.min(rect.width, window.innerWidth - 24); Object.assign(this.menu.style, {left:`${Math.max(12, Math.min(rect.left, window.innerWidth-width-12))}px`, top:`${rect.bottom+6}px`, width:`${width}px`, maxHeight:`${Math.max(44, Math.min(320,window.innerHeight-rect.bottom-18))}px`}); } private enabledOptions(): HTMLButtonElement[] { return Array.from(this.menu?.querySelectorAll('button:not(:disabled)') || []); } private menuKeydown(event: KeyboardEvent): void { if (event.key === 'Tab') { this.close(); return; } const options = this.enabledOptions(); if (!options.length) return; let index = options.indexOf(document.activeElement as HTMLButtonElement); if (event.key === 'ArrowDown') index = (index+1)%options.length; else if (event.key === 'ArrowUp') index = (index-1+options.length)%options.length; else if (event.key === 'Home') index = 0; else if (event.key === 'End') index = options.length-1; else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && event.key !== ' ') { this.search = Date.now()-this.searchedAt > 800 ? event.key : this.search+event.key; this.searchedAt = Date.now(); index = options.findIndex(option => option.textContent?.toLocaleLowerCase().startsWith(this.search.toLocaleLowerCase())); } else return; event.preventDefault(); if (index>=0) { options[index].focus({preventScroll:true}); options[index].scrollIntoView({block:'nearest'}); } } private close(restoreFocus = true): void { if (!this.menu) return; this.menuCleanup.forEach(dispose => dispose());this.menuCleanup=[]; this.menu.remove(); this.menu=undefined; this.trigger?.setAttribute('aria-expanded','false'); this.trigger?.removeAttribute('aria-controls'); if (DropdownDirective.active===this) DropdownDirective.active=undefined; if (restoreFocus) this.trigger?.focus({preventScroll:true}); } private listen(target: unknown, event: string, callback: (event: any) => void): void { this.cleanup.push(this.renderer.listen(target, event, callback)); } ngOnDestroy(): void { this.destroyed=true;this.close(false);this.observer?.disconnect();this.cleanup.forEach(dispose=>dispose());this.trigger?.remove(); this.select.classList.remove('xs-dropdown-native'); for(const [name,value] of [['tabindex',this.originalTabIndex],['aria-hidden',this.originalAriaHidden]] as const) value===null?this.select.removeAttribute(name):this.select.setAttribute(name,value); } }