/
githubmirror
/
components
Обзор
Документация
Войти
/
githubmirror
/
components
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/cdk/overlay/overlay-ref.ts
594 строки
19 KB
Kristiyan Kostadinov
refactor(cdk/overlay): add way to only handle specific events in overlay
10 фев 2026, 22:42
10 фев 2026, 22:42
b8eb7ec
Код
Авторство
О чём код?
/** * @license * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ import {Location} from '@angular/common'; import { AfterRenderRef, ComponentRef, EmbeddedViewRef, EnvironmentInjector, NgZone, Renderer2, afterNextRender, } from '@angular/core'; import {Observable, Subject, Subscription, SubscriptionLike} from 'rxjs'; import {Direction, Directionality} from '../bidi'; import {coerceArray, coerceCssPixelValue} from '../coercion'; import {ComponentPortal, Portal, PortalOutlet, TemplatePortal} from '../portal'; import {BackdropRef} from './backdrop-ref'; import {OverlayKeyboardDispatcher} from './dispatchers/overlay-keyboard-dispatcher'; import {OverlayOutsideClickDispatcher} from './dispatchers/overlay-outside-click-dispatcher'; import {OverlayConfig} from './overlay-config'; import {PositionStrategy} from './position/position-strategy'; import {ScrollStrategy} from './scroll'; /** An object where all of its properties cannot be written. */ export type ImmutableObject<T> = { readonly [P in keyof T]: T[P]; }; /** Checks if a value is an element. */ export function isElement(value: any): value is Element { return value && (value as Element).nodeType === 1; } /** * Reference to an overlay that has been created with the Overlay service. * Used to manipulate or dispose of said overlay. */ export class OverlayRef implements PortalOutlet { private readonly _backdropClick = new Subject<MouseEvent>(); private readonly _attachments = new Subject<void>(); private readonly _detachments = new Subject<void>(); private _positionStrategy: PositionStrategy | undefined; private _scrollStrategy: ScrollStrategy | undefined; private _locationChanges: SubscriptionLike = Subscription.EMPTY; private _backdropRef: BackdropRef | null = null; private _detachContentMutationObserver: MutationObserver | undefined; private _detachContentAfterRenderRef: AfterRenderRef | undefined; private _disposed = false; /** * Reference to the parent of the `_host` at the time it was detached. Used to restore * the `_host` to its original position in the DOM when it gets re-attached. */ private _previousHostParent!: HTMLElement; /** Stream of keydown events dispatched to this overlay. */ readonly _keydownEvents = new Subject<KeyboardEvent>(); /** Stream of mouse outside events dispatched to this overlay. */ readonly _outsidePointerEvents = new Subject<MouseEvent>(); /** Reference to the currently-running `afterNextRender` call. */ private _afterNextRenderRef: AfterRenderRef | undefined; constructor( private _portalOutlet: PortalOutlet, private _host: HTMLElement, private _pane: HTMLElement, private _config: ImmutableObject<OverlayConfig>, private _ngZone: NgZone, private _keyboardDispatcher: OverlayKeyboardDispatcher, private _document: Document, private _location: Location, private _outsideClickDispatcher: OverlayOutsideClickDispatcher, private _animationsDisabled = false, private _injector: EnvironmentInjector, private _renderer: Renderer2, ) { if (_config.scrollStrategy) { this._scrollStrategy = _config.scrollStrategy; this._scrollStrategy.attach(this); } this._positionStrategy = _config.positionStrategy; } /** The overlay's HTML element */ get overlayElement(): HTMLElement { return this._pane; } /** The overlay's backdrop HTML element. */ get backdropElement(): HTMLElement | null { return this._backdropRef?.element || null; } /** * Wrapper around the panel element. Can be used for advanced * positioning where a wrapper with specific styling is * required around the overlay pane. */ get hostElement(): HTMLElement { return this._host; } /** * Function that determines if this overlay should receive a specific event. */ get eventPredicate(): ((event: Event) => boolean) | null { // Note: the safe read here is redundant, but some internal tests mock out the overlay ref. return this._config?.eventPredicate || null; } attach<T>(portal: ComponentPortal<T>): ComponentRef<T>; attach<T>(portal: TemplatePortal<T>): EmbeddedViewRef<T>; attach(portal: any): any; /** * Attaches content, given via a Portal, to the overlay. * If the overlay is configured to have a backdrop, it will be created. * * @param portal Portal instance to which to attach the overlay. * @returns The portal attachment result. */ attach(portal: Portal<any>): any { if (this._disposed) { return null; } // Insert the host into the DOM before attaching the portal, otherwise // the animations module will skip animations on repeat attachments. this._attachHost(); const attachResult = this._portalOutlet.attach(portal); this._positionStrategy?.attach(this); this._updateStackingOrder(); this._updateElementSize(); this._updateElementDirection(); if (this._scrollStrategy) { this._scrollStrategy.enable(); } // We need to clean this up ourselves, because we're passing in an // `EnvironmentInjector` below which won't ever be destroyed. // Otherwise it causes some callbacks to be retained (see #29696). this._afterNextRenderRef?.destroy(); // Update the position once the overlay is fully rendered before attempting to position it, // as the position may depend on the size of the rendered content. this._afterNextRenderRef = afterNextRender( () => { // The overlay could've been detached before the callback executed. if (this.hasAttached()) { this.updatePosition(); } }, {injector: this._injector}, ); // Enable pointer events for the overlay pane element. this._togglePointerEvents(true); if (this._config.hasBackdrop) { this._attachBackdrop(); } if (this._config.panelClass) { this._toggleClasses(this._pane, this._config.panelClass, true); } // Only emit the `attachments` event once all other setup is done. this._attachments.next(); this._completeDetachContent(); // Track this overlay by the keyboard dispatcher this._keyboardDispatcher.add(this); if (this._config.disposeOnNavigation) { this._locationChanges = this._location.subscribe(() => this.dispose()); } this._outsideClickDispatcher.add(this); // TODO(crisbeto): the null check is here, because the portal outlet returns `any`. // We should be guaranteed for the result to be `ComponentRef | EmbeddedViewRef`, but // `instanceof EmbeddedViewRef` doesn't appear to work at the moment. if (typeof attachResult?.onDestroy === 'function') { // In most cases we control the portal and we know when it is being detached so that // we can finish the disposal process. The exception is if the user passes in a custom // `ViewContainerRef` that isn't destroyed through the overlay API. Note that we use // `detach` here instead of `dispose`, because we don't know if the user intends to // reattach the overlay at a later point. It also has the advantage of waiting for animations. attachResult.onDestroy(() => { if (this.hasAttached()) { // We have to delay the `detach` call, because detaching immediately prevents // other destroy hooks from running. This is likely a framework bug similar to // https://github.com/angular/angular/issues/46119 this._ngZone.runOutsideAngular(() => Promise.resolve().then(() => this.detach())); } }); } return attachResult; } /** * Detaches an overlay from a portal. * @returns The portal detachment result. */ detach(): any { if (!this.hasAttached()) { return; } this.detachBackdrop(); // When the overlay is detached, the pane element should disable pointer events. // This is necessary because otherwise the pane element will cover the page and disable // pointer events therefore. Depends on the position strategy and the applied pane boundaries. this._togglePointerEvents(false); if (this._positionStrategy && this._positionStrategy.detach) { this._positionStrategy.detach(); } if (this._scrollStrategy) { this._scrollStrategy.disable(); } const detachmentResult = this._portalOutlet.detach(); // Only emit after everything is detached. this._detachments.next(); this._completeDetachContent(); // Remove this overlay from keyboard dispatcher tracking. this._keyboardDispatcher.remove(this); // Keeping the host element in the DOM can cause scroll jank, because it still gets // rendered, even though it's transparent and unclickable which is why we remove it. this._detachContentWhenEmpty(); this._locationChanges.unsubscribe(); this._outsideClickDispatcher.remove(this); return detachmentResult; } /** Cleans up the overlay from the DOM. */ dispose(): void { if (this._disposed) { return; } const isAttached = this.hasAttached(); if (this._positionStrategy) { this._positionStrategy.dispose(); } this._disposeScrollStrategy(); this._backdropRef?.dispose(); this._locationChanges.unsubscribe(); this._keyboardDispatcher.remove(this); this._portalOutlet.dispose(); this._attachments.complete(); this._backdropClick.complete(); this._keydownEvents.complete(); this._outsidePointerEvents.complete(); this._outsideClickDispatcher.remove(this); this._host?.remove(); this._afterNextRenderRef?.destroy(); this._previousHostParent = this._pane = this._host = this._backdropRef = null!; if (isAttached) { this._detachments.next(); } this._detachments.complete(); this._completeDetachContent(); this._disposed = true; } /** Whether the overlay has attached content. */ hasAttached(): boolean { return this._portalOutlet.hasAttached(); } /** Gets an observable that emits when the backdrop has been clicked. */ backdropClick(): Observable<MouseEvent> { return this._backdropClick; } /** Gets an observable that emits when the overlay has been attached. */ attachments(): Observable<void> { return this._attachments; } /** Gets an observable that emits when the overlay has been detached. */ detachments(): Observable<void> { return this._detachments; } /** Gets an observable of keydown events targeted to this overlay. */ keydownEvents(): Observable<KeyboardEvent> { return this._keydownEvents; } /** Gets an observable of pointer events targeted outside this overlay. */ outsidePointerEvents(): Observable<MouseEvent> { return this._outsidePointerEvents; } /** Gets the current overlay configuration, which is immutable. */ getConfig(): OverlayConfig { return this._config; } /** Updates the position of the overlay based on the position strategy. */ updatePosition(): void { if (this._positionStrategy) { this._positionStrategy.apply(); } } /** Switches to a new position strategy and updates the overlay position. */ updatePositionStrategy(strategy: PositionStrategy): void { if (strategy === this._positionStrategy) { return; } if (this._positionStrategy) { this._positionStrategy.dispose(); } this._positionStrategy = strategy; if (this.hasAttached()) { strategy.attach(this); this.updatePosition(); } } /** Update the size properties of the overlay. */ updateSize(sizeConfig: OverlaySizeConfig): void { this._config = {...this._config, ...sizeConfig}; this._updateElementSize(); } /** Sets the LTR/RTL direction for the overlay. */ setDirection(dir: Direction | Directionality): void { this._config = {...this._config, direction: dir}; this._updateElementDirection(); } /** Add a CSS class or an array of classes to the overlay pane. */ addPanelClass(classes: string | string[]): void { if (this._pane) { this._toggleClasses(this._pane, classes, true); } } /** Remove a CSS class or an array of classes from the overlay pane. */ removePanelClass(classes: string | string[]): void { if (this._pane) { this._toggleClasses(this._pane, classes, false); } } /** * Returns the layout direction of the overlay panel. */ getDirection(): Direction { const direction = this._config.direction; if (!direction) { return 'ltr'; } return typeof direction === 'string' ? direction : direction.value; } /** Switches to a new scroll strategy. */ updateScrollStrategy(strategy: ScrollStrategy): void { if (strategy === this._scrollStrategy) { return; } this._disposeScrollStrategy(); this._scrollStrategy = strategy; if (this.hasAttached()) { strategy.attach(this); strategy.enable(); } } /** Updates the text direction of the overlay panel. */ private _updateElementDirection() { this._host.setAttribute('dir', this.getDirection()); } /** Updates the size of the overlay element based on the overlay config. */ private _updateElementSize() { if (!this._pane) { return; } const style = this._pane.style; style.width = coerceCssPixelValue(this._config.width); style.height = coerceCssPixelValue(this._config.height); style.minWidth = coerceCssPixelValue(this._config.minWidth); style.minHeight = coerceCssPixelValue(this._config.minHeight); style.maxWidth = coerceCssPixelValue(this._config.maxWidth); style.maxHeight = coerceCssPixelValue(this._config.maxHeight); } /** Toggles the pointer events for the overlay pane element. */ private _togglePointerEvents(enablePointer: boolean) { this._pane.style.pointerEvents = enablePointer ? '' : 'none'; } private _attachHost() { if (!this._host.parentElement) { const customInsertionPoint = this._config.usePopover ? this._positionStrategy?.getPopoverInsertionPoint?.() : null; if (isElement(customInsertionPoint)) { customInsertionPoint.after(this._host); } else if (customInsertionPoint?.type === 'parent') { customInsertionPoint.element.appendChild(this._host); } else { this._previousHostParent?.appendChild(this._host); } } if (this._config.usePopover) { // We need the try/catch because the browser will throw if the // host or any of the parents are outside the DOM. Also note // the string access which is there for compatibility with Closure. try { this._host['showPopover'](); } catch {} } } /** Attaches a backdrop for this overlay. */ private _attachBackdrop() { const showingClass = 'cdk-overlay-backdrop-showing'; this._backdropRef?.dispose(); this._backdropRef = new BackdropRef(this._document, this._renderer, this._ngZone, event => { this._backdropClick.next(event); }); if (this._animationsDisabled) { this._backdropRef.element.classList.add('cdk-overlay-backdrop-noop-animation'); } if (this._config.backdropClass) { this._toggleClasses(this._backdropRef.element, this._config.backdropClass, true); } if (this._config.usePopover) { // When using popovers, the backdrop needs to be inside the popover. this._host.prepend(this._backdropRef.element); } else { // Insert the backdrop before the pane in the DOM order, // in order to handle stacked overlays properly. this._host.parentElement!.insertBefore(this._backdropRef.element, this._host); } // Add class to fade-in the backdrop after one frame. if (!this._animationsDisabled && typeof requestAnimationFrame !== 'undefined') { this._ngZone.runOutsideAngular(() => { requestAnimationFrame(() => this._backdropRef?.element.classList.add(showingClass)); }); } else { this._backdropRef.element.classList.add(showingClass); } } /** * Updates the stacking order of the element, moving it to the top if necessary. * This is required in cases where one overlay was detached, while another one, * that should be behind it, was destroyed. The next time both of them are opened, * the stacking will be wrong, because the detached element's pane will still be * in its original DOM position. */ private _updateStackingOrder() { if (!this._config.usePopover && this._host.nextSibling) { this._host.parentNode!.appendChild(this._host); } } /** Detaches the backdrop (if any) associated with the overlay. */ detachBackdrop(): void { if (this._animationsDisabled) { this._backdropRef?.dispose(); this._backdropRef = null; } else { this._backdropRef?.detach(); } } /** Toggles a single CSS class or an array of classes on an element. */ private _toggleClasses(element: HTMLElement, cssClasses: string | string[], isAdd: boolean) { const classes = coerceArray(cssClasses || []).filter(c => !!c); if (classes.length) { isAdd ? element.classList.add(...classes) : element.classList.remove(...classes); } } /** Detaches the overlay once the content finishes animating and is removed from the DOM. */ private _detachContentWhenEmpty() { let rethrow = false; // Attempt to detach on the next render. try { this._detachContentAfterRenderRef = afterNextRender( () => { // Rethrow if we encounter an actual error detaching. rethrow = true; this._detachContent(); }, { injector: this._injector, }, ); } catch (e) { if (rethrow) { throw e; } // afterNextRender throws if the EnvironmentInjector is has already been destroyed. // This may happen in tests that don't properly flush all async work. // In order to avoid breaking those tests, we just detach immediately in this case. this._detachContent(); } // Otherwise wait until the content finishes animating out and detach. if (globalThis.MutationObserver && this._pane) { this._detachContentMutationObserver ||= new globalThis.MutationObserver(() => { this._detachContent(); }); this._detachContentMutationObserver.observe(this._pane, {childList: true}); } } private _detachContent() { // Needs a couple of checks for the pane and host, because // they may have been removed by the time the zone stabilizes. if (!this._pane || !this._host || this._pane.children.length === 0) { if (this._pane && this._config.panelClass) { this._toggleClasses(this._pane, this._config.panelClass, false); } if (this._host && this._host.parentElement) { this._previousHostParent = this._host.parentElement; this._host.remove(); } this._completeDetachContent(); } } private _completeDetachContent() { this._detachContentAfterRenderRef?.destroy(); this._detachContentAfterRenderRef = undefined; this._detachContentMutationObserver?.disconnect(); } /** Disposes of a scroll strategy. */ private _disposeScrollStrategy() { const scrollStrategy = this._scrollStrategy; scrollStrategy?.disable(); scrollStrategy?.detach?.(); } } /** Size properties for an overlay. */ export interface OverlaySizeConfig { width?: number | string; height?: number | string; minWidth?: number | string; minHeight?: number | string; maxWidth?: number | string; maxHeight?: number | string; }