dropdown.directive.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. import { AfterViewChecked, AfterViewInit, Directive, ElementRef, inject, NgZone, OnDestroy, Renderer2 } from '@angular/core';
  2. /** Keeps native Angular select bindings while providing one consistent, anchored picker. */
  3. @Directive({selector: 'select', standalone: true})
  4. export class DropdownDirective implements AfterViewInit, AfterViewChecked, OnDestroy {
  5. private static nextId = 0;
  6. private static active?: DropdownDirective;
  7. private readonly select = inject<ElementRef<HTMLSelectElement>>(ElementRef).nativeElement;
  8. private readonly renderer = inject(Renderer2);
  9. private readonly zone = inject(NgZone);
  10. private readonly id = `xs-dropdown-${++DropdownDirective.nextId}`;
  11. private trigger?: HTMLButtonElement;
  12. private label?: HTMLSpanElement;
  13. private menu?: HTMLDivElement;
  14. private observer?: MutationObserver;
  15. private readonly cleanup: Array<() => void> = [];
  16. private menuCleanup: Array<() => void> = [];
  17. private pending = false;
  18. private destroyed = false;
  19. private signature = '';
  20. private search = '';
  21. private searchedAt = 0;
  22. private originalTabIndex: string | null = null;
  23. private originalAriaHidden: string | null = null;
  24. ngAfterViewInit(): void {
  25. const select = this.select;
  26. // Current app controls are all single-select; don't silently change multi-select semantics.
  27. if (select.multiple || select.size > 1) return;
  28. const trigger = this.trigger = document.createElement('button');
  29. trigger.type = 'button';
  30. trigger.className = 'xs-dropdown-trigger';
  31. trigger.setAttribute('role', 'combobox');
  32. trigger.setAttribute('aria-haspopup', 'listbox');
  33. trigger.setAttribute('aria-expanded', 'false');
  34. this.label = document.createElement('span');
  35. const arrow = document.createElement('i');
  36. arrow.setAttribute('aria-hidden', 'true');
  37. trigger.append(this.label, arrow);
  38. select.insertAdjacentElement('afterend', trigger);
  39. this.originalTabIndex = select.getAttribute('tabindex');
  40. this.originalAriaHidden = select.getAttribute('aria-hidden');
  41. select.classList.add('xs-dropdown-native');
  42. select.tabIndex = -1;
  43. select.setAttribute('aria-hidden', 'true');
  44. this.listen(trigger, 'click', (event: Event) => { event.preventDefault(); this.menu ? this.close() : this.open(); });
  45. this.listen(trigger, 'keydown', (event: KeyboardEvent) => {
  46. if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) { event.preventDefault(); this.open(event.key); }
  47. else if (event.key === 'Escape') this.close();
  48. });
  49. this.listen(select, 'focus', () => trigger.focus());
  50. this.listen(select, 'change', () => this.sync());
  51. this.listen(trigger, 'blur', () => select.dispatchEvent(new Event('blur')));
  52. if (select.form) this.listen(select.form, 'reset', () => this.scheduleSync());
  53. const outsideClick = (event: MouseEvent) => {
  54. const target = event.target as Node;
  55. if (this.menu && !trigger.contains(target) && !this.menu.contains(target) && target !== select) this.close(false);
  56. };
  57. const escape = (event: KeyboardEvent) => {
  58. if (this.menu && event.key === 'Escape') { event.preventDefault(); event.stopImmediatePropagation(); this.close(); }
  59. };
  60. // Capture so modal containers that stop bubbling cannot trap the open menu.
  61. document.addEventListener('click', outsideClick, true);
  62. document.addEventListener('keydown', escape, true);
  63. this.cleanup.push(() => document.removeEventListener('click', outsideClick, true), () => document.removeEventListener('keydown', escape, true));
  64. this.listen(window, 'resize', () => this.positionMenu());
  65. const onScroll = (event: Event) => { if (this.menu && !this.menu.contains(event.target as Node)) this.positionMenu(); };
  66. document.addEventListener('scroll', onScroll, true);
  67. this.cleanup.push(() => document.removeEventListener('scroll', onScroll, true));
  68. this.observer = new MutationObserver(() => this.scheduleSync());
  69. this.observer.observe(select, {subtree:true, childList:true, characterData:true, attributes:true, attributeFilter:['disabled','label','value','selected','aria-label','aria-required','required']});
  70. this.sync();
  71. }
  72. ngAfterViewChecked(): void { this.scheduleSync(); }
  73. private scheduleSync(): void {
  74. if (this.pending || this.destroyed || !this.trigger) return;
  75. this.pending = true;
  76. this.zone.runOutsideAngular(() => queueMicrotask(() => { this.pending = false; if (!this.destroyed) this.sync(); }));
  77. }
  78. private optionLabel(option: HTMLOptionElement): string {
  79. return option.label.trim() || option.textContent?.trim() || '未命名选项';
  80. }
  81. private accessibleLabel(): string {
  82. const explicit = this.select.getAttribute('aria-label');
  83. if (explicit) return explicit;
  84. const label = this.select.labels?.[0];
  85. if (!label) return '选择选项';
  86. const copy = label.cloneNode(true) as HTMLElement;
  87. copy.querySelectorAll('select,button,input,textarea,svg').forEach(element => element.remove());
  88. return copy.textContent?.trim() || '选择选项';
  89. }
  90. private sync(): void {
  91. if (!this.trigger || !this.label) return;
  92. this.label.textContent = this.select.selectedOptions[0] ? this.optionLabel(this.select.selectedOptions[0]) : '请选择';
  93. this.trigger.disabled = this.select.disabled;
  94. this.trigger.setAttribute('aria-label', this.accessibleLabel());
  95. this.trigger.setAttribute('aria-required', String(this.select.required || this.select.getAttribute('aria-required') === 'true'));
  96. 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]));
  97. if (this.menu && (signature !== this.signature || this.select.disabled)) this.close(false);
  98. this.signature = signature;
  99. }
  100. private open(key?: string): void {
  101. this.sync();
  102. if (!this.trigger || this.trigger.disabled || this.menu) return;
  103. DropdownDirective.active?.close(false);
  104. // Make room below the control even when it is near the bottom of a dialog or viewport.
  105. if (window.innerHeight - this.trigger.getBoundingClientRect().bottom < 150) this.trigger.scrollIntoView({block:'center', behavior:'instant'});
  106. const menu = this.menu = document.createElement('div');
  107. menu.className = 'xs-dropdown-menu';
  108. menu.id = this.id;
  109. menu.setAttribute('role', 'listbox');
  110. menu.setAttribute('aria-label', this.accessibleLabel());
  111. for (const option of Array.from(this.select.options)) {
  112. if (option.hidden) continue;
  113. const button = document.createElement('button');
  114. button.type = 'button';
  115. button.setAttribute('role', 'option');
  116. button.setAttribute('aria-selected', String(option.selected));
  117. button.textContent = this.optionLabel(option);
  118. button.disabled = option.disabled || (option.parentElement instanceof HTMLOptGroupElement && option.parentElement.disabled);
  119. button.tabIndex = -1;
  120. menu.append(button);
  121. this.menuCleanup.push(this.renderer.listen(button, 'click', () => {
  122. if (this.select.disabled || button.disabled) return;
  123. this.select.selectedIndex = option.index;
  124. this.close();
  125. this.select.dispatchEvent(new Event('change', {bubbles:true}));
  126. this.sync();
  127. }));
  128. }
  129. if (!menu.children.length) { const empty=document.createElement('p');empty.textContent='暂无可选项';menu.append(empty); }
  130. // A viewport overlay escapes scroll cards and modal overflow clipping.
  131. document.body.append(menu);
  132. this.positionMenu();
  133. this.trigger.setAttribute('aria-expanded', 'true');
  134. this.trigger.setAttribute('aria-controls', this.id);
  135. DropdownDirective.active = this;
  136. this.menuCleanup.push(this.renderer.listen(menu, 'keydown', (event: KeyboardEvent) => this.menuKeydown(event)));
  137. const enabled = this.enabledOptions();
  138. const selected = enabled.find(button => button.getAttribute('aria-selected') === 'true');
  139. const target = key==='Home' ? enabled[0] : key==='End' ? enabled.at(-1) : selected || (key==='ArrowUp' ? enabled.at(-1) : enabled[0]);
  140. target?.focus({preventScroll:true});
  141. if (target) menu.scrollTop = Math.max(0, target.offsetTop - menu.clientHeight/2);
  142. }
  143. private positionMenu(): void {
  144. if (!this.menu || !this.trigger) return;
  145. const rect = this.trigger.getBoundingClientRect();
  146. if (rect.bottom < 0 || rect.top > window.innerHeight) { this.close(false); return; }
  147. const width = Math.min(rect.width, window.innerWidth - 24);
  148. 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`});
  149. }
  150. private enabledOptions(): HTMLButtonElement[] { return Array.from(this.menu?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)') || []); }
  151. private menuKeydown(event: KeyboardEvent): void {
  152. if (event.key === 'Tab') { this.close(); return; }
  153. const options = this.enabledOptions();
  154. if (!options.length) return;
  155. let index = options.indexOf(document.activeElement as HTMLButtonElement);
  156. if (event.key === 'ArrowDown') index = (index+1)%options.length;
  157. else if (event.key === 'ArrowUp') index = (index-1+options.length)%options.length;
  158. else if (event.key === 'Home') index = 0;
  159. else if (event.key === 'End') index = options.length-1;
  160. else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && event.key !== ' ') {
  161. this.search = Date.now()-this.searchedAt > 800 ? event.key : this.search+event.key;
  162. this.searchedAt = Date.now();
  163. index = options.findIndex(option => option.textContent?.toLocaleLowerCase().startsWith(this.search.toLocaleLowerCase()));
  164. } else return;
  165. event.preventDefault();
  166. if (index>=0) { options[index].focus({preventScroll:true}); options[index].scrollIntoView({block:'nearest'}); }
  167. }
  168. private close(restoreFocus = true): void {
  169. if (!this.menu) return;
  170. this.menuCleanup.forEach(dispose => dispose());this.menuCleanup=[];
  171. this.menu.remove(); this.menu=undefined;
  172. this.trigger?.setAttribute('aria-expanded','false');
  173. this.trigger?.removeAttribute('aria-controls');
  174. if (DropdownDirective.active===this) DropdownDirective.active=undefined;
  175. if (restoreFocus) this.trigger?.focus({preventScroll:true});
  176. }
  177. private listen(target: unknown, event: string, callback: (event: any) => void): void { this.cleanup.push(this.renderer.listen(target, event, callback)); }
  178. ngOnDestroy(): void {
  179. this.destroyed=true;this.close(false);this.observer?.disconnect();this.cleanup.forEach(dispose=>dispose());this.trigger?.remove();
  180. this.select.classList.remove('xs-dropdown-native');
  181. 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);
  182. }
  183. }