|
|
@@ -1,188 +1 @@
|
|
|
-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 = `mobile-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);
|
|
|
- }
|
|
|
-}
|
|
|
+export { DropdownDirective } from '../../../../shared/ui/dropdown.directive';
|