| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188 |
- 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<HTMLSelectElement>>(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<HTMLButtonElement>('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);
- }
- }
|