Add popover.component.ts
Create the following file in your project.
import { Directionality, type Direction } from '@angular/cdk/bidi';
import {
type ConnectedPosition,
type FlexibleConnectedPositionStrategy,
Overlay,
OverlayPositionBuilder,
type OverlayRef,
} from '@angular/cdk/overlay';
import { TemplatePortal } from '@angular/cdk/portal';
import { isPlatformBrowser } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
contentChild,
DestroyRef,
Directive,
DOCUMENT,
ElementRef,
inject,
input,
numberAttribute,
type OnDestroy,
type OnInit,
output,
PLATFORM_ID,
Renderer2,
RendererStyleFlags2,
signal,
type TemplateRef,
viewChild,
ViewContainerRef,
ViewEncapsulation,
} from '@angular/core';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import type { ClassValue } from 'clsx';
import { filter, type Subscription } from 'rxjs';
import { ZardIdDirective } from '@/shared/core';
import { mergeClasses } from '@/shared/utils/merge-classes';
import {
popoverDescriptionVariants,
popoverHeaderVariants,
popoverTitleVariants,
popoverVariants,
} from './popover.variants';
export type ZardPopoverTrigger = 'click' | 'hover' | null;
export type ZardPopoverPlacement = 'top' | 'bottom' | 'left' | 'right' | 'inline-start' | 'inline-end';
export type ZardPopoverAlign = 'start' | 'center' | 'end';
/**
* Kept in sync with the `duration-100` utility of `popoverVariants`. The exit animation runs for this long
* before the overlay is detached, so tests must advance timers by this exact amount.
*/
export const ZARD_POPOVER_ANIMATION_DURATION = 100;
const CONTENT_SELECTOR = '[data-slot="popover-content"]';
const FALLBACK_PLACEMENTS: Record<ZardPopoverPlacement, ZardPopoverPlacement[]> = {
top: ['bottom', 'right', 'left'],
bottom: ['top', 'right', 'left'],
left: ['right', 'bottom', 'top'],
right: ['left', 'bottom', 'top'],
'inline-start': ['inline-end', 'bottom', 'top'],
'inline-end': ['inline-start', 'bottom', 'top'],
};
const ALIGN_TO_VERTICAL = { start: 'top', center: 'center', end: 'bottom' } as const;
const ALIGN_TO_ORIGIN_Y = { start: 'top', center: 'center', end: 'bottom' } as const;
function isVerticalPlacement(placement: ZardPopoverPlacement): boolean {
return placement === 'top' || placement === 'bottom';
}
function isInlinePlacement(placement: ZardPopoverPlacement): boolean {
return placement === 'inline-start' || placement === 'inline-end';
}
/** Whether the panel ends up on the physical left of the trigger (`left`/`right` are physical, `inline-*` use dir). */
function isPhysicalLeft(placement: ZardPopoverPlacement, rtl: boolean): boolean {
if (placement === 'left') return true;
if (placement === 'right') return false;
return (placement === 'inline-start') !== rtl;
}
function buildPosition(
placement: ZardPopoverPlacement,
align: ZardPopoverAlign,
sideOffset: number,
alignOffset: number,
rtl: boolean,
): ConnectedPosition {
if (isVerticalPlacement(placement)) {
const isTop = placement === 'top';
return {
originX: align,
originY: isTop ? 'top' : 'bottom',
overlayX: align,
overlayY: isTop ? 'bottom' : 'top',
// `align` start/end are logical (CDK flips them in RTL); the offset is physical, so it is mirrored.
offsetX: rtl ? -alignOffset : alignOffset,
offsetY: isTop ? -sideOffset : sideOffset,
};
}
const onLeft = isPhysicalLeft(placement, rtl);
// CDK `start`/`end` follow the overlay direction, so a physical side must be swapped in RTL.
const isStart = onLeft !== rtl;
const verticalAlign = ALIGN_TO_VERTICAL[align];
return {
originX: isStart ? 'start' : 'end',
originY: verticalAlign,
overlayX: isStart ? 'end' : 'start',
overlayY: verticalAlign,
offsetX: onLeft ? -sideOffset : sideOffset,
offsetY: alignOffset,
};
}
function transformOriginFor(side: ZardPopoverPlacement, align: ZardPopoverAlign, rtl: boolean): string {
// `align` is logical on the horizontal axis: start is the physical right in RTL.
const originX = align === 'center' ? 'center' : (align === 'start') !== rtl ? 'left' : 'right';
switch (side) {
case 'top':
return `${originX} bottom`;
case 'bottom':
return `${originX} top`;
default:
return `${isPhysicalLeft(side, rtl) ? 'right' : 'left'} ${ALIGN_TO_ORIGIN_Y[align]}`;
}
}
@Directive({
selector: '[zPopover]',
host: {
'data-slot': 'popover-trigger',
'[attr.aria-haspopup]': '"dialog"',
'[attr.aria-expanded]': 'isVisible()',
'[attr.aria-controls]': 'contentId()',
},
exportAs: 'zPopover',
})
export class ZardPopoverDirective implements OnInit, OnDestroy {
private readonly destroyRef = inject(DestroyRef);
private readonly document = inject(DOCUMENT);
private readonly overlay = inject(Overlay);
private readonly overlayPositionBuilder = inject(OverlayPositionBuilder);
private readonly elementRef = inject(ElementRef);
private readonly renderer = inject(Renderer2);
private readonly viewContainerRef = inject(ViewContainerRef);
private readonly platformId = inject(PLATFORM_ID);
// Optional: the direction of the trigger, which is where the overlay content takes its `dir` from.
private readonly directionality = inject(Directionality, { optional: true });
private overlayRef?: OverlayRef;
private positionStrategy?: FlexibleConnectedPositionStrategy;
private overlayRefSubscription?: Subscription;
private listeners: (() => void)[] = [];
private animationListener?: () => void;
private detachTimer?: ReturnType<typeof setTimeout>;
readonly zTrigger = input<ZardPopoverTrigger>('click');
readonly zContent = input.required<TemplateRef<unknown>>();
readonly zPlacement = input<ZardPopoverPlacement>('bottom');
readonly zAlign = input<ZardPopoverAlign>('center');
readonly zSideOffset = input(4, { transform: numberAttribute });
readonly zAlignOffset = input(0, { transform: numberAttribute });
readonly zOrigin = input<ElementRef>();
readonly zVisible = input<boolean>(false);
readonly zOverlayClickable = input<boolean>(true);
readonly zVisibleChange = output<boolean>();
protected readonly isVisible = signal(false);
protected readonly contentId = signal<string | null>(null);
get nativeElement() {
return this.zOrigin()?.nativeElement ?? this.elementRef.nativeElement;
}
constructor() {
toObservable(this.zVisible)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(visible => {
const currentlyVisible = this.isVisible();
if (visible && !currentlyVisible) {
this.show();
} else if (!visible && currentlyVisible) {
this.hide();
}
});
// The direction changed while the panel is open (a `[dir]` switch): close it, it reopens with the new direction.
this.directionality?.change.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.overlayRef?.setDirection(this.direction());
if (this.isVisible()) {
this.hide();
}
});
toObservable(this.zTrigger)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(trigger => {
if (this.listeners.length) {
this.unlistenAll();
}
this.setupTriggers();
this.overlayRefSubscription?.unsubscribe();
this.overlayRefSubscription = undefined;
if (trigger === 'click') {
this.subscribeToOverlayRef();
}
});
}
ngOnInit() {
this.createOverlay();
}
ngOnDestroy() {
this.unlistenAll();
this.cancelPendingDetach();
this.overlayRefSubscription?.unsubscribe();
this.overlayRef?.dispose();
}
show() {
if (this.isVisible()) {
return;
}
if (!this.overlayRef) {
this.createOverlay();
}
const reopening = this.overlayRef?.hasAttached() ?? false;
if (reopening) {
// The exit animation is still running: reuse the attached portal instead of creating a second one.
this.cancelPendingDetach();
}
this.overlayRef?.setDirection(this.direction());
this.positionStrategy?.withPositions(this.getPositions());
if (reopening) {
this.overlayRef?.updatePosition();
} else {
this.overlayRef?.attach(new TemplatePortal(this.zContent(), this.viewContainerRef));
}
this.isVisible.set(true);
this.applyOpenState();
this.zVisibleChange.emit(true);
}
hide() {
this.close(this.isFocusWithinOverlay());
}
toggle() {
if (this.isVisible()) {
this.hide();
} else {
this.show();
}
}
private close(restoreFocus: boolean) {
if (!this.isVisible()) {
return;
}
const content = this.contentElement();
if (content) {
this.renderer.removeAttribute(content, 'data-open');
this.renderer.setAttribute(content, 'data-closed', '');
}
this.isVisible.set(false);
this.contentId.set(null);
if (restoreFocus) {
this.nativeElement?.focus?.();
}
this.scheduleDetach(content);
this.zVisibleChange.emit(false);
}
private createOverlay() {
if (!isPlatformBrowser(this.platformId)) {
return;
}
const positionStrategy = this.overlayPositionBuilder
.flexibleConnectedTo(this.nativeElement)
.withPositions(this.getPositions())
.withPush(false)
.withFlexibleDimensions(false)
.withViewportMargin(8);
this.positionStrategy = positionStrategy;
this.overlayRef = this.overlay.create({
positionStrategy,
direction: this.direction(),
hasBackdrop: false,
scrollStrategy: this.overlay.scrollStrategies.reposition(),
});
// Mirrors the `isolate z-50` the shadcn positioner puts on its own wrapper.
this.overlayRef.addPanelClass(['isolate', 'z-50']);
positionStrategy.positionChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(change => this.applyPosition(change.connectionPair));
this.overlayRef
.keydownEvents()
.pipe(
filter(event => event.key === 'Escape' && this.isVisible()),
takeUntilDestroyed(this.destroyRef),
)
.subscribe(event => {
event.preventDefault();
event.stopPropagation();
this.close(true);
});
}
private subscribeToOverlayRef(): void {
if (
this.zOverlayClickable() &&
this.zTrigger() === 'click' &&
isPlatformBrowser(this.platformId) &&
this.overlayRef
) {
this.overlayRefSubscription = this.overlayRef
.outsidePointerEvents()
.pipe(filter(event => !this.nativeElement.contains(event.target)))
.subscribe(() => this.hide());
}
}
private setupTriggers() {
const trigger = this.zTrigger();
if (!trigger) {
return;
}
if (trigger === 'click') {
this.listeners.push(this.renderer.listen(this.nativeElement, 'click.stop', () => this.toggle()));
} else if (trigger === 'hover') {
this.listeners.push(this.renderer.listen(this.nativeElement, 'mouseenter', () => this.show()));
this.listeners.push(this.renderer.listen(this.nativeElement, 'mouseleave', () => this.hide()));
}
}
private unlistenAll(): void {
for (const listener of this.listeners) {
listener();
}
this.listeners = [];
}
private direction(): Direction {
return this.directionality?.value ?? 'ltr';
}
private getPositions(): ConnectedPosition[] {
const placement = this.zPlacement();
const align = this.zAlign();
const sideOffset = this.zSideOffset();
const alignOffset = this.zAlignOffset();
return [placement, ...FALLBACK_PLACEMENTS[placement]].map(fallback =>
buildPosition(fallback, align, sideOffset, alignOffset, this.direction() === 'rtl'),
);
}
/**
* The template portal is attached straight into the overlay pane, so the styled content element is looked up by
* its slot. Falls back to the pane itself when the consumer does not use `z-popover`.
*/
private contentElement(): HTMLElement | null {
const pane = this.overlayRef?.overlayElement;
if (!pane) {
return null;
}
return pane.querySelector<HTMLElement>(CONTENT_SELECTOR) ?? pane;
}
private applyOpenState(): void {
const content = this.contentElement();
if (!content) {
return;
}
this.renderer.removeAttribute(content, 'data-closed');
this.renderer.setAttribute(content, 'data-open', '');
if (!content.hasAttribute('data-side')) {
const [primary] = this.getPositions();
this.applyPosition(primary);
}
this.contentId.set(content.getAttribute('id'));
}
private applyPosition(position: ConnectedPosition): void {
const content = this.contentElement();
if (!content) {
return;
}
const side = this.resolveSide(position);
const align = this.resolveAlign(position, side);
this.renderer.setAttribute(content, 'data-side', side);
this.renderer.setAttribute(content, 'data-align', align);
this.renderer.setStyle(
content,
'--transform-origin',
transformOriginFor(side, align, this.direction() === 'rtl'),
RendererStyleFlags2.DashCase,
);
}
private resolveSide(position: ConnectedPosition): ZardPopoverPlacement {
const inline = isInlinePlacement(this.zPlacement());
if (position.originY === 'top' && position.overlayY === 'bottom') {
return 'top';
}
if (position.originY === 'bottom' && position.overlayY === 'top') {
return 'bottom';
}
// CDK start/end follow the overlay direction; `left`/`right` are physical, so swap them in RTL.
const cdkStart = position.originX === 'start' && position.overlayX === 'end';
if (inline) {
return cdkStart ? 'inline-start' : 'inline-end';
}
return cdkStart !== (this.direction() === 'rtl') ? 'left' : 'right';
}
private resolveAlign(position: ConnectedPosition, side: ZardPopoverPlacement): ZardPopoverAlign {
if (isVerticalPlacement(side)) {
return position.overlayX;
}
if (position.overlayY === 'top') {
return 'start';
}
if (position.overlayY === 'bottom') {
return 'end';
}
return 'center';
}
private isFocusWithinOverlay(): boolean {
const pane = this.overlayRef?.overlayElement;
const active = this.document.activeElement;
return !!pane && !!active && pane.contains(active);
}
private scheduleDetach(content: HTMLElement | null): void {
this.cancelPendingDetach();
if (!this.overlayRef?.hasAttached()) {
return;
}
if (!content || this.prefersReducedMotion()) {
this.overlayRef.detach();
return;
}
this.animationListener = this.renderer.listen(content, 'animationend', (event: AnimationEvent) => {
if (event.target === content) {
this.detachNow();
}
});
// happy-dom never fires `animationend`, so the timer is the only guaranteed path in tests.
this.detachTimer = setTimeout(() => this.detachNow(), ZARD_POPOVER_ANIMATION_DURATION);
}
private detachNow(): void {
this.cancelPendingDetach();
this.overlayRef?.detach();
}
private cancelPendingDetach(): void {
if (this.detachTimer !== undefined) {
clearTimeout(this.detachTimer);
this.detachTimer = undefined;
}
this.animationListener?.();
this.animationListener = undefined;
}
private prefersReducedMotion(): boolean {
if (!isPlatformBrowser(this.platformId)) {
return true;
}
return this.document.defaultView?.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
}
}
@Component({
selector: 'z-popover-title, [z-popover-title]',
imports: [ZardIdDirective],
template: `
<ng-container zardId="popover-title" #uniqueId="zardId" />
<ng-content />
`,
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
host: {
'data-slot': 'popover-title',
'[attr.id]': 'id()',
'[class]': 'classes()',
},
exportAs: 'zPopoverTitle',
})
export class ZardPopoverTitleComponent {
readonly class = input<ClassValue>('');
private readonly uniqueId = viewChild<ZardIdDirective>('uniqueId');
readonly id = computed(() => this.uniqueId()?.id() ?? null);
protected readonly classes = computed(() => mergeClasses(popoverTitleVariants(), this.class()));
}
@Component({
selector: 'z-popover-description, [z-popover-description]',
imports: [ZardIdDirective],
template: `
<ng-container zardId="popover-description" #uniqueId="zardId" />
<ng-content />
`,
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
host: {
'data-slot': 'popover-description',
'[attr.id]': 'id()',
'[class]': 'classes()',
},
exportAs: 'zPopoverDescription',
})
export class ZardPopoverDescriptionComponent {
readonly class = input<ClassValue>('');
private readonly uniqueId = viewChild<ZardIdDirective>('uniqueId');
readonly id = computed(() => this.uniqueId()?.id() ?? null);
protected readonly classes = computed(() => mergeClasses(popoverDescriptionVariants(), this.class()));
}
@Component({
selector: 'z-popover-header, [z-popover-header]',
template: `
<ng-content />
`,
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
host: {
'data-slot': 'popover-header',
'[class]': 'classes()',
},
exportAs: 'zPopoverHeader',
})
export class ZardPopoverHeaderComponent {
readonly class = input<ClassValue>('');
protected readonly classes = computed(() => mergeClasses(popoverHeaderVariants(), this.class()));
}
@Component({
selector: 'z-popover, [z-popover]',
imports: [ZardIdDirective],
template: `
<ng-container zardId="popover" #uniqueId="zardId" />
<ng-content />
`,
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
host: {
'data-slot': 'popover-content',
role: 'dialog',
'[attr.id]': 'id()',
'[attr.aria-labelledby]': 'labelledBy()',
'[attr.aria-describedby]': 'describedBy()',
'[class]': 'classes()',
},
exportAs: 'zPopoverContent',
})
export class ZardPopoverComponent {
readonly class = input<ClassValue>('');
private readonly uniqueId = viewChild<ZardIdDirective>('uniqueId');
private readonly title = contentChild(ZardPopoverTitleComponent);
private readonly description = contentChild(ZardPopoverDescriptionComponent);
readonly id = computed(() => this.uniqueId()?.id() ?? null);
protected readonly labelledBy = computed(() => this.title()?.id() ?? null);
protected readonly describedBy = computed(() => this.description()?.id() ?? null);
protected readonly classes = computed(() => mergeClasses(popoverVariants(), this.class()));
}