From 75d7e3ed0822c3c46909994975425243c0f7f6d9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pawe=C5=82=20Twardziak?=
Date: Sun, 29 Dec 2024 23:27:05 +0100
Subject: [PATCH 01/11] fix(popover): adjust popover stories description
---
packages/primitives/popover/stories/popover.docs.mdx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/primitives/popover/stories/popover.docs.mdx b/packages/primitives/popover/stories/popover.docs.mdx
index a9242cab..4ae7d8e7 100644
--- a/packages/primitives/popover/stories/popover.docs.mdx
+++ b/packages/primitives/popover/stories/popover.docs.mdx
@@ -13,7 +13,7 @@ import {RdxPopoverContentAttributesComponent} from "../src/popover-content-attri
# Popover
-#### A popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it.
+#### A popup that displays information ready to interact with related to an element when the element receives click on it.
## Examples
### Default
From 0c3a9fc6729493f24a97250bf14cd33ae6f4e079 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pawe=C5=82=20Twardziak?=
Date: Sun, 29 Dec 2024 23:31:18 +0100
Subject: [PATCH 02/11] fix(core): skip readonly mode when destructuring
rdx_positions
---
packages/primitives/core/src/positioning/utils.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/primitives/core/src/positioning/utils.ts b/packages/primitives/core/src/positioning/utils.ts
index 13a3552c..7a3d2762 100644
--- a/packages/primitives/core/src/positioning/utils.ts
+++ b/packages/primitives/core/src/positioning/utils.ts
@@ -13,7 +13,7 @@ export function getContentPosition(
sideAndAlignWithOffsets: RdxPositionSideAndAlign & RdxPositionSideAndAlignOffsets
): ConnectedPosition {
const { side, align, sideOffset, alignOffset } = sideAndAlignWithOffsets;
- const position = {
+ const position: ConnectedPosition = {
...(RDX_POSITIONS[side]?.[align] ?? RDX_POSITIONS[RdxPositionSide.Top][RdxPositionAlign.Center])
};
if (sideOffset || alignOffset) {
From b07d177064bb1d6026c979d3a02e38fbfc9f753c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pawe=C5=82=20Twardziak?=
Date: Sun, 29 Dec 2024 23:50:24 +0100
Subject: [PATCH 03/11] chore(tooltip): copy popover to tooltip-v2 and rename
files
---
packages/primitives/tooltip-v2/README.md | 3 +
packages/primitives/tooltip-v2/index.ts | 32 ++
.../primitives/tooltip-v2/ng-package.json | 5 +
.../src/tooltip-anchor.directive.ts | 67 ++++
.../tooltip-v2/src/tooltip-anchor.token.ts | 4 +
.../tooltip-v2/src/tooltip-arrow.directive.ts | 149 +++++++
.../tooltip-v2/src/tooltip-arrow.token.ts | 4 +
.../tooltip-v2/src/tooltip-close.directive.ts | 44 +++
.../tooltip-v2/src/tooltip-close.token.ts | 4 +
.../tooltip-content-attributes.component.ts | 65 ++++
.../src/tooltip-content-attributes.token.ts | 6 +
.../src/tooltip-content.directive.ts | 356 +++++++++++++++++
.../tooltip-v2/src/tooltip-root.directive.ts | 367 ++++++++++++++++++
.../tooltip-v2/src/tooltip-root.inject.ts | 9 +
.../src/tooltip-trigger.directive.ts | 33 ++
.../tooltip-v2/src/tooltip.types.ts | 16 +
.../tooltip-v2/src/utils/cdk-event.service.ts | 203 ++++++++++
.../tooltip-v2/src/utils/constants.ts | 1 +
.../primitives/tooltip-v2/src/utils/types.ts | 7 +
.../stories/tooltip-anchor.component.ts | 165 ++++++++
.../stories/tooltip-animations.component.ts | 121 ++++++
.../stories/tooltip-default.component.ts | 91 +++++
.../stories/tooltip-events.components.ts | 98 +++++
.../tooltip-initially-open.component.ts | 92 +++++
.../stories/tooltip-multiple.component.ts | 222 +++++++++++
.../stories/tooltip-positioning.component.ts | 136 +++++++
.../stories/tooltip-triggering.component.ts | 224 +++++++++++
.../tooltip-v2/stories/tooltip.docs.mdx | 156 ++++++++
.../tooltip-v2/stories/tooltip.stories.ts | 114 ++++++
.../tooltip-v2/stories/utils/constants.ts | 2 +
.../stories/utils/containers.registry.ts | 63 +++
.../stories/utils/option-panel-base.class.ts | 36 ++
.../stories/utils/styles.constants.ts | 352 +++++++++++++++++
.../tooltip-v2/stories/utils/types.ts | 20 +
.../utils/with-option-panel.component.ts | 180 +++++++++
35 files changed, 3447 insertions(+)
create mode 100644 packages/primitives/tooltip-v2/README.md
create mode 100644 packages/primitives/tooltip-v2/index.ts
create mode 100644 packages/primitives/tooltip-v2/ng-package.json
create mode 100644 packages/primitives/tooltip-v2/src/tooltip-anchor.directive.ts
create mode 100644 packages/primitives/tooltip-v2/src/tooltip-anchor.token.ts
create mode 100644 packages/primitives/tooltip-v2/src/tooltip-arrow.directive.ts
create mode 100644 packages/primitives/tooltip-v2/src/tooltip-arrow.token.ts
create mode 100644 packages/primitives/tooltip-v2/src/tooltip-close.directive.ts
create mode 100644 packages/primitives/tooltip-v2/src/tooltip-close.token.ts
create mode 100644 packages/primitives/tooltip-v2/src/tooltip-content-attributes.component.ts
create mode 100644 packages/primitives/tooltip-v2/src/tooltip-content-attributes.token.ts
create mode 100644 packages/primitives/tooltip-v2/src/tooltip-content.directive.ts
create mode 100644 packages/primitives/tooltip-v2/src/tooltip-root.directive.ts
create mode 100644 packages/primitives/tooltip-v2/src/tooltip-root.inject.ts
create mode 100644 packages/primitives/tooltip-v2/src/tooltip-trigger.directive.ts
create mode 100644 packages/primitives/tooltip-v2/src/tooltip.types.ts
create mode 100644 packages/primitives/tooltip-v2/src/utils/cdk-event.service.ts
create mode 100644 packages/primitives/tooltip-v2/src/utils/constants.ts
create mode 100644 packages/primitives/tooltip-v2/src/utils/types.ts
create mode 100644 packages/primitives/tooltip-v2/stories/tooltip-anchor.component.ts
create mode 100644 packages/primitives/tooltip-v2/stories/tooltip-animations.component.ts
create mode 100644 packages/primitives/tooltip-v2/stories/tooltip-default.component.ts
create mode 100644 packages/primitives/tooltip-v2/stories/tooltip-events.components.ts
create mode 100644 packages/primitives/tooltip-v2/stories/tooltip-initially-open.component.ts
create mode 100644 packages/primitives/tooltip-v2/stories/tooltip-multiple.component.ts
create mode 100644 packages/primitives/tooltip-v2/stories/tooltip-positioning.component.ts
create mode 100644 packages/primitives/tooltip-v2/stories/tooltip-triggering.component.ts
create mode 100644 packages/primitives/tooltip-v2/stories/tooltip.docs.mdx
create mode 100644 packages/primitives/tooltip-v2/stories/tooltip.stories.ts
create mode 100644 packages/primitives/tooltip-v2/stories/utils/constants.ts
create mode 100644 packages/primitives/tooltip-v2/stories/utils/containers.registry.ts
create mode 100644 packages/primitives/tooltip-v2/stories/utils/option-panel-base.class.ts
create mode 100644 packages/primitives/tooltip-v2/stories/utils/styles.constants.ts
create mode 100644 packages/primitives/tooltip-v2/stories/utils/types.ts
create mode 100644 packages/primitives/tooltip-v2/stories/utils/with-option-panel.component.ts
diff --git a/packages/primitives/tooltip-v2/README.md b/packages/primitives/tooltip-v2/README.md
new file mode 100644
index 00000000..7e25f59f
--- /dev/null
+++ b/packages/primitives/tooltip-v2/README.md
@@ -0,0 +1,3 @@
+# @radix-ng/primitives/tooltip-v2
+
+Secondary entry point of `@radix-ng/primitives`. It can be used by importing from `@radix-ng/primitives/tooltip-v2`.
diff --git a/packages/primitives/tooltip-v2/index.ts b/packages/primitives/tooltip-v2/index.ts
new file mode 100644
index 00000000..6d88e4f2
--- /dev/null
+++ b/packages/primitives/tooltip-v2/index.ts
@@ -0,0 +1,32 @@
+import { NgModule } from '@angular/core';
+import { RdxPopoverAnchorDirective } from './src/tooltip-anchor.directive';
+import { RdxPopoverArrowDirective } from './src/tooltip-arrow.directive';
+import { RdxPopoverCloseDirective } from './src/tooltip-close.directive';
+import { RdxPopoverContentAttributesComponent } from './src/tooltip-content-attributes.component';
+import { RdxPopoverContentDirective } from './src/tooltip-content.directive';
+import { RdxPopoverRootDirective } from './src/tooltip-root.directive';
+import { RdxPopoverTriggerDirective } from './src/tooltip-trigger.directive';
+
+export * from './src/tooltip-anchor.directive';
+export * from './src/tooltip-arrow.directive';
+export * from './src/tooltip-close.directive';
+export * from './src/tooltip-content-attributes.component';
+export * from './src/tooltip-content.directive';
+export * from './src/tooltip-root.directive';
+export * from './src/tooltip-trigger.directive';
+
+const _imports = [
+ RdxPopoverArrowDirective,
+ RdxPopoverCloseDirective,
+ RdxPopoverContentDirective,
+ RdxPopoverTriggerDirective,
+ RdxPopoverRootDirective,
+ RdxPopoverAnchorDirective,
+ RdxPopoverContentAttributesComponent
+];
+
+@NgModule({
+ imports: [..._imports],
+ exports: [..._imports]
+})
+export class RdxPopoverModule {}
diff --git a/packages/primitives/tooltip-v2/ng-package.json b/packages/primitives/tooltip-v2/ng-package.json
new file mode 100644
index 00000000..bebf62dc
--- /dev/null
+++ b/packages/primitives/tooltip-v2/ng-package.json
@@ -0,0 +1,5 @@
+{
+ "lib": {
+ "entryFile": "index.ts"
+ }
+}
diff --git a/packages/primitives/tooltip-v2/src/tooltip-anchor.directive.ts b/packages/primitives/tooltip-v2/src/tooltip-anchor.directive.ts
new file mode 100644
index 00000000..74dc7d18
--- /dev/null
+++ b/packages/primitives/tooltip-v2/src/tooltip-anchor.directive.ts
@@ -0,0 +1,67 @@
+import { CdkOverlayOrigin } from '@angular/cdk/overlay';
+import { computed, Directive, ElementRef, forwardRef, inject } from '@angular/core';
+import { injectDocument } from '@radix-ng/primitives/core';
+import { RdxPopoverAnchorToken } from './tooltip-anchor.token';
+import { RdxPopoverRootDirective } from './tooltip-root.directive';
+import { injectPopoverRoot } from './tooltip-root.inject';
+
+@Directive({
+ selector: '[rdxPopoverAnchor]',
+ exportAs: 'rdxPopoverAnchor',
+ hostDirectives: [CdkOverlayOrigin],
+ host: {
+ type: 'button',
+ '[attr.id]': 'name()',
+ '[attr.aria-haspopup]': '"dialog"',
+ '(click)': 'click()'
+ },
+ providers: [
+ {
+ provide: RdxPopoverAnchorToken,
+ useExisting: forwardRef(() => RdxPopoverAnchorDirective)
+ }
+ ]
+})
+export class RdxPopoverAnchorDirective {
+ /**
+ * @ignore
+ * If outside the root then null, otherwise the root directive - with optional `true` passed in as the first param.
+ * If outside the root and non-null value that means the html structure is wrong - popover inside popover.
+ * */
+ protected popoverRoot = injectPopoverRoot(true);
+ /** @ignore */
+ readonly elementRef = inject(ElementRef);
+ /** @ignore */
+ readonly overlayOrigin = inject(CdkOverlayOrigin);
+ /** @ignore */
+ readonly document = injectDocument();
+
+ /** @ignore */
+ readonly name = computed(() => `rdx-popover-external-anchor-${this.popoverRoot?.uniqueId()}`);
+
+ /** @ignore */
+ click(): void {
+ this.emitOutsideClick();
+ }
+
+ /** @ignore */
+ setPopoverRoot(popoverRoot: RdxPopoverRootDirective) {
+ this.popoverRoot = popoverRoot;
+ }
+
+ private emitOutsideClick() {
+ if (
+ !this.popoverRoot?.isOpen() ||
+ this.popoverRoot?.popoverContentDirective().onOverlayOutsideClickDisabled()
+ ) {
+ return;
+ }
+ const clickEvent = new MouseEvent('click', {
+ view: this.document.defaultView,
+ bubbles: true,
+ cancelable: true,
+ relatedTarget: this.elementRef.nativeElement
+ });
+ this.popoverRoot?.popoverTriggerDirective().elementRef.nativeElement.dispatchEvent(clickEvent);
+ }
+}
diff --git a/packages/primitives/tooltip-v2/src/tooltip-anchor.token.ts b/packages/primitives/tooltip-v2/src/tooltip-anchor.token.ts
new file mode 100644
index 00000000..49bd5d10
--- /dev/null
+++ b/packages/primitives/tooltip-v2/src/tooltip-anchor.token.ts
@@ -0,0 +1,4 @@
+import { InjectionToken } from '@angular/core';
+import { RdxPopoverAnchorDirective } from './tooltip-anchor.directive';
+
+export const RdxPopoverAnchorToken = new InjectionToken('RdxPopoverAnchorToken');
diff --git a/packages/primitives/tooltip-v2/src/tooltip-arrow.directive.ts b/packages/primitives/tooltip-v2/src/tooltip-arrow.directive.ts
new file mode 100644
index 00000000..d27d1eb0
--- /dev/null
+++ b/packages/primitives/tooltip-v2/src/tooltip-arrow.directive.ts
@@ -0,0 +1,149 @@
+import { NumberInput } from '@angular/cdk/coercion';
+import { ConnectedOverlayPositionChange } from '@angular/cdk/overlay';
+import {
+ afterNextRender,
+ computed,
+ Directive,
+ effect,
+ ElementRef,
+ forwardRef,
+ inject,
+ input,
+ numberAttribute,
+ Renderer2,
+ signal,
+ untracked
+} from '@angular/core';
+import { toSignal } from '@angular/core/rxjs-interop';
+import {
+ getArrowPositionParams,
+ getSideAndAlignFromAllPossibleConnectedPositions,
+ RDX_POSITIONING_DEFAULTS
+} from '@radix-ng/primitives/core';
+import { RdxPopoverArrowToken } from './tooltip-arrow.token';
+import { injectPopoverRoot } from './tooltip-root.inject';
+
+@Directive({
+ selector: '[rdxPopoverArrow]',
+ providers: [
+ {
+ provide: RdxPopoverArrowToken,
+ useExisting: forwardRef(() => RdxPopoverArrowDirective)
+ }
+ ]
+})
+export class RdxPopoverArrowDirective {
+ /** @ignore */
+ private readonly renderer = inject(Renderer2);
+ /** @ignore */
+ private readonly popoverRoot = injectPopoverRoot();
+ /** @ignore */
+ readonly elementRef = inject(ElementRef);
+
+ /**
+ * @description The width of the arrow in pixels.
+ * @default 10
+ */
+ readonly width = input(RDX_POSITIONING_DEFAULTS.arrow.width, { transform: numberAttribute });
+
+ /**
+ * @description The height of the arrow in pixels.
+ * @default 5
+ */
+ readonly height = input(RDX_POSITIONING_DEFAULTS.arrow.height, { transform: numberAttribute });
+
+ /** @ignore */
+ readonly arrowSvgElement = computed(() => {
+ const width = this.width();
+ const height = this.height();
+
+ const svgElement = this.renderer.createElement('svg', 'svg');
+ this.renderer.setAttribute(svgElement, 'viewBox', '0 0 30 10');
+ this.renderer.setAttribute(svgElement, 'width', String(width));
+ this.renderer.setAttribute(svgElement, 'height', String(height));
+ const polygonElement = this.renderer.createElement('polygon', 'svg');
+ this.renderer.setAttribute(polygonElement, 'points', '0,0 30,0 15,10');
+ this.renderer.setAttribute(svgElement, 'preserveAspectRatio', 'none');
+ this.renderer.appendChild(svgElement, polygonElement);
+
+ return svgElement;
+ });
+
+ /** @ignore */
+ private readonly currentArrowSvgElement = signal(void 0);
+ /** @ignore */
+ private readonly position = toSignal(this.popoverRoot.popoverContentDirective().positionChange());
+
+ /** @ignore */
+ private anchorOrTriggerRect: DOMRect;
+
+ constructor() {
+ afterNextRender({
+ write: () => {
+ if (this.elementRef.nativeElement.parentElement) {
+ this.renderer.setStyle(this.elementRef.nativeElement.parentElement, 'position', 'relative');
+ }
+ this.renderer.setStyle(this.elementRef.nativeElement, 'position', 'absolute');
+ this.renderer.setStyle(this.elementRef.nativeElement, 'boxSizing', '');
+ this.renderer.setStyle(this.elementRef.nativeElement, 'fontSize', '0px');
+ }
+ });
+ this.onArrowSvgElementChangeEffect();
+ this.onContentPositionAndArrowDimensionsChangeEffect();
+ }
+
+ /** @ignore */
+ private setAnchorOrTriggerRect() {
+ this.anchorOrTriggerRect = (
+ this.popoverRoot.popoverAnchorDirective() ?? this.popoverRoot.popoverTriggerDirective()
+ ).elementRef.nativeElement.getBoundingClientRect();
+ }
+
+ /** @ignore */
+ private setPosition(position: ConnectedOverlayPositionChange, arrowDimensions: { width: number; height: number }) {
+ this.setAnchorOrTriggerRect();
+ const posParams = getArrowPositionParams(
+ getSideAndAlignFromAllPossibleConnectedPositions(position.connectionPair),
+ { width: arrowDimensions.width, height: arrowDimensions.height },
+ { width: this.anchorOrTriggerRect.width, height: this.anchorOrTriggerRect.height }
+ );
+
+ this.renderer.setStyle(this.elementRef.nativeElement, 'top', posParams.top);
+ this.renderer.setStyle(this.elementRef.nativeElement, 'bottom', '');
+ this.renderer.setStyle(this.elementRef.nativeElement, 'left', posParams.left);
+ this.renderer.setStyle(this.elementRef.nativeElement, 'right', '');
+ this.renderer.setStyle(this.elementRef.nativeElement, 'transform', posParams.transform);
+ this.renderer.setStyle(this.elementRef.nativeElement, 'transformOrigin', posParams.transformOrigin);
+ }
+
+ /** @ignore */
+ private onArrowSvgElementChangeEffect() {
+ effect(() => {
+ const arrowElement = this.arrowSvgElement();
+ untracked(() => {
+ const currentArrowSvgElement = this.currentArrowSvgElement();
+ if (currentArrowSvgElement) {
+ this.renderer.removeChild(this.elementRef.nativeElement, currentArrowSvgElement);
+ }
+ this.currentArrowSvgElement.set(arrowElement);
+ this.renderer.setStyle(this.elementRef.nativeElement, 'width', `${this.width()}px`);
+ this.renderer.setStyle(this.elementRef.nativeElement, 'height', `${this.height()}px`);
+ this.renderer.appendChild(this.elementRef.nativeElement, this.currentArrowSvgElement());
+ });
+ });
+ }
+
+ /** @ignore */
+ private onContentPositionAndArrowDimensionsChangeEffect() {
+ effect(() => {
+ const position = this.position();
+ const arrowDimensions = { width: this.width(), height: this.height() };
+ untracked(() => {
+ if (!position) {
+ return;
+ }
+ this.setPosition(position, arrowDimensions);
+ });
+ });
+ }
+}
diff --git a/packages/primitives/tooltip-v2/src/tooltip-arrow.token.ts b/packages/primitives/tooltip-v2/src/tooltip-arrow.token.ts
new file mode 100644
index 00000000..a99039bc
--- /dev/null
+++ b/packages/primitives/tooltip-v2/src/tooltip-arrow.token.ts
@@ -0,0 +1,4 @@
+import { InjectionToken } from '@angular/core';
+import { RdxPopoverArrowDirective } from './tooltip-arrow.directive';
+
+export const RdxPopoverArrowToken = new InjectionToken('RdxPopoverArrowToken');
diff --git a/packages/primitives/tooltip-v2/src/tooltip-close.directive.ts b/packages/primitives/tooltip-v2/src/tooltip-close.directive.ts
new file mode 100644
index 00000000..8d53dc2c
--- /dev/null
+++ b/packages/primitives/tooltip-v2/src/tooltip-close.directive.ts
@@ -0,0 +1,44 @@
+import { Directive, effect, ElementRef, forwardRef, inject, Renderer2, untracked } from '@angular/core';
+import { RdxPopoverCloseToken } from './tooltip-close.token';
+import { injectPopoverRoot } from './tooltip-root.inject';
+
+@Directive({
+ selector: '[rdxPopoverClose]',
+ host: {
+ type: 'button',
+ '(click)': 'popoverRoot.handleClose()'
+ },
+ providers: [
+ {
+ provide: RdxPopoverCloseToken,
+ useExisting: forwardRef(() => RdxPopoverCloseDirective)
+ }
+ ]
+})
+export class RdxPopoverCloseDirective {
+ /** @ignore */
+ protected readonly popoverRoot = injectPopoverRoot();
+ /** @ignore */
+ readonly elementRef = inject(ElementRef);
+ /** @ignore */
+ private readonly renderer = inject(Renderer2);
+
+ constructor() {
+ this.onIsControlledExternallyEffect();
+ }
+
+ /** @ignore */
+ private onIsControlledExternallyEffect() {
+ effect(() => {
+ const isControlledExternally = this.popoverRoot.controlledExternally()();
+
+ untracked(() => {
+ this.renderer.setStyle(
+ this.elementRef.nativeElement,
+ 'display',
+ isControlledExternally ? 'none' : null
+ );
+ });
+ });
+ }
+}
diff --git a/packages/primitives/tooltip-v2/src/tooltip-close.token.ts b/packages/primitives/tooltip-v2/src/tooltip-close.token.ts
new file mode 100644
index 00000000..5cc244ea
--- /dev/null
+++ b/packages/primitives/tooltip-v2/src/tooltip-close.token.ts
@@ -0,0 +1,4 @@
+import { InjectionToken } from '@angular/core';
+import { RdxPopoverCloseDirective } from './tooltip-close.directive';
+
+export const RdxPopoverCloseToken = new InjectionToken('RdxPopoverCloseToken');
diff --git a/packages/primitives/tooltip-v2/src/tooltip-content-attributes.component.ts b/packages/primitives/tooltip-v2/src/tooltip-content-attributes.component.ts
new file mode 100644
index 00000000..12fc4943
--- /dev/null
+++ b/packages/primitives/tooltip-v2/src/tooltip-content-attributes.component.ts
@@ -0,0 +1,65 @@
+import { ChangeDetectionStrategy, Component, computed, forwardRef } from '@angular/core';
+import { RdxPopoverContentAttributesToken } from './tooltip-content-attributes.token';
+import { injectPopoverRoot } from './tooltip-root.inject';
+import { RdxPopoverAnimationStatus, RdxPopoverState } from './tooltip.types';
+
+@Component({
+ selector: '[rdxPopoverContentAttributes]',
+ template: `
+
+ `,
+ host: {
+ '[attr.role]': '"dialog"',
+ '[attr.id]': 'name()',
+ '[attr.data-state]': 'popoverRoot.state()',
+ '[attr.data-side]': 'popoverRoot.popoverContentDirective().side()',
+ '[attr.data-align]': 'popoverRoot.popoverContentDirective().align()',
+ '[style]': 'disableAnimation() ? {animation: "none !important"} : null',
+ '(animationstart)': 'onAnimationStart($event)',
+ '(animationend)': 'onAnimationEnd($event)'
+ },
+ providers: [
+ {
+ provide: RdxPopoverContentAttributesToken,
+ useExisting: forwardRef(() => RdxPopoverContentAttributesComponent)
+ }
+ ],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class RdxPopoverContentAttributesComponent {
+ /** @ignore */
+ protected readonly popoverRoot = injectPopoverRoot();
+
+ /** @ignore */
+ readonly name = computed(() => `rdx-popover-content-attributes-${this.popoverRoot.uniqueId()}`);
+
+ /** @ignore */
+ readonly disableAnimation = computed(() => !this.canAnimate());
+
+ /** @ignore */
+ protected onAnimationStart(_: AnimationEvent) {
+ this.popoverRoot.cssAnimationStatus.set(
+ this.popoverRoot.state() === RdxPopoverState.OPEN
+ ? RdxPopoverAnimationStatus.OPEN_STARTED
+ : RdxPopoverAnimationStatus.CLOSED_STARTED
+ );
+ }
+
+ /** @ignore */
+ protected onAnimationEnd(_: AnimationEvent) {
+ this.popoverRoot.cssAnimationStatus.set(
+ this.popoverRoot.state() === RdxPopoverState.OPEN
+ ? RdxPopoverAnimationStatus.OPEN_ENDED
+ : RdxPopoverAnimationStatus.CLOSED_ENDED
+ );
+ }
+
+ /** @ignore */
+ private canAnimate() {
+ return (
+ this.popoverRoot.cssAnimation() &&
+ ((this.popoverRoot.cssOpeningAnimation() && this.popoverRoot.state() === RdxPopoverState.OPEN) ||
+ (this.popoverRoot.cssClosingAnimation() && this.popoverRoot.state() === RdxPopoverState.CLOSED))
+ );
+ }
+}
diff --git a/packages/primitives/tooltip-v2/src/tooltip-content-attributes.token.ts b/packages/primitives/tooltip-v2/src/tooltip-content-attributes.token.ts
new file mode 100644
index 00000000..8e4ae123
--- /dev/null
+++ b/packages/primitives/tooltip-v2/src/tooltip-content-attributes.token.ts
@@ -0,0 +1,6 @@
+import { InjectionToken } from '@angular/core';
+import { RdxPopoverContentAttributesComponent } from './tooltip-content-attributes.component';
+
+export const RdxPopoverContentAttributesToken = new InjectionToken(
+ 'RdxPopoverContentAttributesToken'
+);
diff --git a/packages/primitives/tooltip-v2/src/tooltip-content.directive.ts b/packages/primitives/tooltip-v2/src/tooltip-content.directive.ts
new file mode 100644
index 00000000..72fc76d2
--- /dev/null
+++ b/packages/primitives/tooltip-v2/src/tooltip-content.directive.ts
@@ -0,0 +1,356 @@
+import { BooleanInput, NumberInput } from '@angular/cdk/coercion';
+import { CdkConnectedOverlay, Overlay } from '@angular/cdk/overlay';
+import {
+ booleanAttribute,
+ computed,
+ DestroyRef,
+ Directive,
+ effect,
+ inject,
+ input,
+ numberAttribute,
+ OnInit,
+ output,
+ SimpleChange,
+ TemplateRef,
+ untracked
+} from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import {
+ getAllPossibleConnectedPositions,
+ getContentPosition,
+ RDX_POSITIONING_DEFAULTS,
+ RdxPositionAlign,
+ RdxPositionSide,
+ RdxPositionSideAndAlignOffsets
+} from '@radix-ng/primitives/core';
+import { filter, tap } from 'rxjs';
+import { injectPopoverRoot } from './tooltip-root.inject';
+import { RdxPopoverAttachDetachEvent } from './tooltip.types';
+
+@Directive({
+ selector: '[rdxPopoverContent]',
+ hostDirectives: [
+ CdkConnectedOverlay
+ ]
+})
+export class RdxPopoverContentDirective implements OnInit {
+ /** @ignore */
+ private readonly popoverRoot = injectPopoverRoot();
+ /** @ignore */
+ private readonly templateRef = inject(TemplateRef);
+ /** @ignore */
+ private readonly overlay = inject(Overlay);
+ /** @ignore */
+ private readonly destroyRef = inject(DestroyRef);
+ /** @ignore */
+ private readonly connectedOverlay = inject(CdkConnectedOverlay);
+
+ /** @ignore */
+ readonly name = computed(() => `rdx-popover-trigger-${this.popoverRoot.uniqueId()}`);
+
+ /**
+ * @description The preferred side of the trigger to render against when open. Will be reversed when collisions occur and avoidCollisions is enabled.
+ * @default top
+ */
+ readonly side = input(RdxPositionSide.Top);
+ /**
+ * @description The distance in pixels from the trigger.
+ * @default undefined
+ */
+ readonly sideOffset = input(NaN, {
+ transform: numberAttribute
+ });
+ /**
+ * @description The preferred alignment against the trigger. May change when collisions occur.
+ * @default center
+ */
+ readonly align = input(RdxPositionAlign.Center);
+ /**
+ * @description An offset in pixels from the "start" or "end" alignment options.
+ * @default undefined
+ */
+ readonly alignOffset = input(NaN, {
+ transform: numberAttribute
+ });
+
+ /**
+ * @description Whether to add some alternate positions of the content.
+ * @default false
+ */
+ readonly alternatePositionsDisabled = input(false, { transform: booleanAttribute });
+
+ /** @description Whether to prevent `onOverlayEscapeKeyDown` handler from calling. */
+ readonly onOverlayEscapeKeyDownDisabled = input(false, { transform: booleanAttribute });
+ /** @description Whether to prevent `onOverlayOutsideClick` handler from calling. */
+ readonly onOverlayOutsideClickDisabled = input(false, { transform: booleanAttribute });
+
+ /**
+ * @description Event handler called when the escape key is down.
+ * It can be prevented by setting `onOverlayEscapeKeyDownDisabled` input to `true`.
+ */
+ readonly onOverlayEscapeKeyDown = output();
+ /**
+ * @description Event handler called when a pointer event occurs outside the bounds of the component.
+ * It can be prevented by setting `onOverlayOutsideClickDisabled` input to `true`.
+ */
+ readonly onOverlayOutsideClick = output();
+
+ /**
+ * @description Event handler called after the overlay is open
+ */
+ readonly onOpen = output();
+ /**
+ * @description Event handler called after the overlay is closed
+ */
+ readonly onClosed = output();
+
+ /** @ingore */
+ readonly positions = computed(() => this.computePositions());
+
+ constructor() {
+ this.onOriginChangeEffect();
+ this.onPositionChangeEffect();
+ }
+
+ /** @ignore */
+ ngOnInit() {
+ this.setScrollStrategy();
+ this.setHasBackdrop();
+ this.setDisableClose();
+ this.onAttach();
+ this.onDetach();
+ this.connectKeydownEscape();
+ this.connectOutsideClick();
+ }
+
+ /** @ignore */
+ open() {
+ if (this.connectedOverlay.open) {
+ return;
+ }
+ const prevOpen = this.connectedOverlay.open;
+ this.connectedOverlay.open = true;
+ this.fireOverlayNgOnChanges('open', this.connectedOverlay.open, prevOpen);
+ }
+
+ /** @ignore */
+ close() {
+ if (!this.connectedOverlay.open) {
+ return;
+ }
+ const prevOpen = this.connectedOverlay.open;
+ this.connectedOverlay.open = false;
+ this.fireOverlayNgOnChanges('open', this.connectedOverlay.open, prevOpen);
+ }
+
+ /** @ignore */
+ positionChange() {
+ return this.connectedOverlay.positionChange.asObservable();
+ }
+
+ /** @ignore */
+ private connectKeydownEscape() {
+ this.connectedOverlay.overlayKeydown
+ .asObservable()
+ .pipe(
+ filter(
+ () =>
+ !this.onOverlayEscapeKeyDownDisabled() &&
+ !this.popoverRoot.rdxCdkEventService?.primitivePreventedFromCdkEvent(
+ this.popoverRoot,
+ 'cdkOverlayEscapeKeyDown'
+ )
+ ),
+ filter((event) => event.key === 'Escape'),
+ tap((event) => {
+ this.onOverlayEscapeKeyDown.emit(event);
+ }),
+ filter(() => !this.popoverRoot.firstDefaultOpen()),
+ tap(() => {
+ this.popoverRoot.handleClose();
+ }),
+ takeUntilDestroyed(this.destroyRef)
+ )
+ .subscribe();
+ }
+
+ /** @ignore */
+ private connectOutsideClick() {
+ this.connectedOverlay.overlayOutsideClick
+ .asObservable()
+ .pipe(
+ filter(
+ () =>
+ !this.onOverlayOutsideClickDisabled() &&
+ !this.popoverRoot.rdxCdkEventService?.primitivePreventedFromCdkEvent(
+ this.popoverRoot,
+ 'cdkOverlayOutsideClick'
+ )
+ ),
+ /**
+ * Handle the situation when an anchor is added and the anchor becomes the origin of the overlay
+ * hence the trigger will be considered the outside element
+ */
+ filter((event) => {
+ return (
+ !this.popoverRoot.popoverAnchorDirective() ||
+ !this.popoverRoot
+ .popoverTriggerDirective()
+ .elementRef.nativeElement.contains(event.target as Element)
+ );
+ }),
+ tap((event) => {
+ this.onOverlayOutsideClick.emit(event);
+ }),
+ filter(() => !this.popoverRoot.firstDefaultOpen()),
+ tap(() => {
+ this.popoverRoot.handleClose();
+ }),
+ takeUntilDestroyed(this.destroyRef)
+ )
+ .subscribe();
+ }
+
+ /** @ignore */
+ private onAttach() {
+ this.connectedOverlay.attach
+ .asObservable()
+ .pipe(
+ tap(() => {
+ /**
+ * `this.onOpen.emit();` is being delegated to the root directive due to the opening animation
+ */
+ this.popoverRoot.attachDetachEvent.set(RdxPopoverAttachDetachEvent.ATTACH);
+ }),
+ takeUntilDestroyed(this.destroyRef)
+ )
+ .subscribe();
+ }
+
+ /** @ignore */
+ private onDetach() {
+ this.connectedOverlay.detach
+ .asObservable()
+ .pipe(
+ tap(() => {
+ /**
+ * `this.onClosed.emit();` is being delegated to the root directive due to the closing animation
+ */
+ this.popoverRoot.attachDetachEvent.set(RdxPopoverAttachDetachEvent.DETACH);
+ }),
+ takeUntilDestroyed(this.destroyRef)
+ )
+ .subscribe();
+ }
+
+ /** @ignore */
+ private setScrollStrategy() {
+ const prevScrollStrategy = this.connectedOverlay.scrollStrategy;
+ this.connectedOverlay.scrollStrategy = this.overlay.scrollStrategies.reposition();
+ this.fireOverlayNgOnChanges('scrollStrategy', this.connectedOverlay.scrollStrategy, prevScrollStrategy);
+ }
+
+ /** @ignore */
+ private setHasBackdrop() {
+ const prevHasBackdrop = this.connectedOverlay.hasBackdrop;
+ this.connectedOverlay.hasBackdrop = false;
+ this.fireOverlayNgOnChanges('hasBackdrop', this.connectedOverlay.hasBackdrop, prevHasBackdrop);
+ }
+
+ /** @ignore */
+ private setDisableClose() {
+ const prevDisableClose = this.connectedOverlay.disableClose;
+ this.connectedOverlay.disableClose = true;
+ this.fireOverlayNgOnChanges('disableClose', this.connectedOverlay.disableClose, prevDisableClose);
+ }
+
+ /** @ignore */
+ private setOrigin(origin: CdkConnectedOverlay['origin']) {
+ const prevOrigin = this.connectedOverlay.origin;
+ this.connectedOverlay.origin = origin;
+ this.fireOverlayNgOnChanges('origin', this.connectedOverlay.origin, prevOrigin);
+ }
+
+ /** @ignore */
+ private setPositions(positions: CdkConnectedOverlay['positions']) {
+ const prevPositions = this.connectedOverlay.positions;
+ this.connectedOverlay.positions = positions;
+ this.fireOverlayNgOnChanges('positions', this.connectedOverlay.positions, prevPositions);
+ this.connectedOverlay.overlayRef?.updatePosition();
+ }
+
+ /** @ignore */
+ private computePositions() {
+ const arrowHeight = this.popoverRoot.popoverArrowDirective()?.height() ?? 0;
+ const offsets: RdxPositionSideAndAlignOffsets = {
+ sideOffset: isNaN(this.sideOffset())
+ ? arrowHeight || RDX_POSITIONING_DEFAULTS.offsets.side
+ : this.sideOffset(),
+ alignOffset: isNaN(this.alignOffset()) ? RDX_POSITIONING_DEFAULTS.offsets.align : this.alignOffset()
+ };
+ const basePosition = getContentPosition({
+ side: this.side(),
+ align: this.align(),
+ sideOffset: offsets.sideOffset,
+ alignOffset: offsets.alignOffset
+ });
+ const positions = [basePosition];
+ if (!this.alternatePositionsDisabled()) {
+ /**
+ * Alternate positions for better user experience along the X/Y axis (e.g. vertical/horizontal scrolling)
+ */
+ const allPossibleConnectedPositions = getAllPossibleConnectedPositions();
+ allPossibleConnectedPositions.forEach((_, key) => {
+ const sideAndAlignArray = key.split('|');
+ if (
+ (sideAndAlignArray[0] as RdxPositionSide) !== this.side() ||
+ (sideAndAlignArray[1] as RdxPositionAlign) !== this.align()
+ ) {
+ positions.push(
+ getContentPosition({
+ side: sideAndAlignArray[0] as RdxPositionSide,
+ align: sideAndAlignArray[1] as RdxPositionAlign,
+ sideOffset: offsets.sideOffset,
+ alignOffset: offsets.alignOffset
+ })
+ );
+ }
+ });
+ }
+ return positions;
+ }
+
+ private onOriginChangeEffect() {
+ effect(() => {
+ const origin = (this.popoverRoot.popoverAnchorDirective() ?? this.popoverRoot.popoverTriggerDirective())
+ .overlayOrigin;
+ untracked(() => {
+ this.setOrigin(origin);
+ });
+ });
+ }
+
+ /** @ignore */
+ private onPositionChangeEffect() {
+ effect(() => {
+ const positions = this.positions();
+ this.alternatePositionsDisabled();
+ untracked(() => {
+ this.setPositions(positions);
+ });
+ });
+ }
+
+ /** @ignore */
+ private fireOverlayNgOnChanges(
+ input: K,
+ currentValue: V,
+ previousValue: V,
+ firstChange = false
+ ) {
+ this.connectedOverlay.ngOnChanges({
+ [input]: new SimpleChange(previousValue, currentValue, firstChange)
+ });
+ }
+}
diff --git a/packages/primitives/tooltip-v2/src/tooltip-root.directive.ts b/packages/primitives/tooltip-v2/src/tooltip-root.directive.ts
new file mode 100644
index 00000000..6766bd01
--- /dev/null
+++ b/packages/primitives/tooltip-v2/src/tooltip-root.directive.ts
@@ -0,0 +1,367 @@
+import { BooleanInput } from '@angular/cdk/coercion';
+import {
+ afterNextRender,
+ booleanAttribute,
+ computed,
+ contentChild,
+ DestroyRef,
+ Directive,
+ effect,
+ inject,
+ input,
+ signal,
+ untracked,
+ ViewContainerRef
+} from '@angular/core';
+import { RdxPopoverAnchorDirective } from './tooltip-anchor.directive';
+import { RdxPopoverAnchorToken } from './tooltip-anchor.token';
+import { RdxPopoverArrowToken } from './tooltip-arrow.token';
+import { RdxPopoverCloseToken } from './tooltip-close.token';
+import { RdxPopoverContentAttributesToken } from './tooltip-content-attributes.token';
+import { RdxPopoverContentDirective } from './tooltip-content.directive';
+import { RdxPopoverTriggerDirective } from './tooltip-trigger.directive';
+import { RdxPopoverAnimationStatus, RdxPopoverAttachDetachEvent, RdxPopoverState } from './tooltip.types';
+import { injectRdxCdkEventService } from './utils/cdk-event.service';
+
+let nextId = 0;
+
+@Directive({
+ selector: '[rdxPopoverRoot]',
+ exportAs: 'rdxPopoverRoot'
+})
+export class RdxPopoverRootDirective {
+ /** @ignore */
+ readonly uniqueId = signal(++nextId);
+ /** @ignore */
+ readonly name = computed(() => `rdx-popover-root-${this.uniqueId()}`);
+
+ /**
+ * @description The anchor directive that comes form outside the popover root
+ * @default undefined
+ */
+ readonly anchor = input(void 0);
+ /**
+ * @description The open state of the popover when it is initially rendered. Use when you do not need to control its open state.
+ * @default false
+ */
+ readonly defaultOpen = input(false, { transform: booleanAttribute });
+ /**
+ * @description The controlled state of the popover. `open` input take precedence of `defaultOpen` input.
+ * @default undefined
+ */
+ readonly open = input(void 0, { transform: booleanAttribute });
+ /**
+ * @description Whether to control the state of the popover from external. Use in conjunction with `open` input.
+ * @default undefined
+ */
+ readonly externalControl = input(void 0, { transform: booleanAttribute });
+ /**
+ * @description Whether to take into account CSS opening/closing animations.
+ * @default false
+ */
+ readonly cssAnimation = input(false, { transform: booleanAttribute });
+ /**
+ * @description Whether to take into account CSS opening animations. `cssAnimation` input must be set to 'true'
+ * @default false
+ */
+ readonly cssOpeningAnimation = input(false, { transform: booleanAttribute });
+ /**
+ * @description Whether to take into account CSS closing animations. `cssAnimation` input must be set to 'true'
+ * @default false
+ */
+ readonly cssClosingAnimation = input(false, { transform: booleanAttribute });
+
+ /** @ignore */
+ readonly cssAnimationStatus = signal(null);
+
+ /** @ignore */
+ readonly popoverContentDirective = contentChild.required(RdxPopoverContentDirective);
+ /** @ignore */
+ readonly popoverTriggerDirective = contentChild.required(RdxPopoverTriggerDirective);
+ /** @ignore */
+ readonly popoverArrowDirective = contentChild(RdxPopoverArrowToken);
+ /** @ignore */
+ readonly popoverCloseDirective = contentChild(RdxPopoverCloseToken);
+ /** @ignore */
+ readonly popoverContentAttributesComponent = contentChild(RdxPopoverContentAttributesToken);
+ /** @ignore */
+ private readonly internalPopoverAnchorDirective = contentChild(RdxPopoverAnchorToken);
+
+ /** @ignore */
+ readonly viewContainerRef = inject(ViewContainerRef);
+ /** @ignore */
+ readonly rdxCdkEventService = injectRdxCdkEventService();
+ /** @ignore */
+ readonly destroyRef = inject(DestroyRef);
+
+ /** @ignore */
+ readonly state = signal(RdxPopoverState.CLOSED);
+
+ /** @ignore */
+ readonly attachDetachEvent = signal(RdxPopoverAttachDetachEvent.DETACH);
+
+ /** @ignore */
+ private readonly isFirstDefaultOpen = signal(false);
+
+ /** @ignore */
+ readonly popoverAnchorDirective = computed(() => this.internalPopoverAnchorDirective() ?? this.anchor());
+
+ constructor() {
+ this.rdxCdkEventService?.registerPrimitive(this);
+ this.destroyRef.onDestroy(() => this.rdxCdkEventService?.deregisterPrimitive(this));
+ this.onStateChangeEffect();
+ this.onCssAnimationStatusChangeChangeEffect();
+ this.onOpenChangeEffect();
+ this.onIsFirstDefaultOpenChangeEffect();
+ this.onAnchorChangeEffect();
+ this.emitOpenOrClosedEventEffect();
+ afterNextRender({
+ write: () => {
+ if (this.defaultOpen() && !this.open()) {
+ this.isFirstDefaultOpen.set(true);
+ }
+ }
+ });
+ }
+
+ /** @ignore */
+ getAnimationParamsSnapshot() {
+ return {
+ cssAnimation: this.cssAnimation(),
+ cssOpeningAnimation: this.cssOpeningAnimation(),
+ cssClosingAnimation: this.cssClosingAnimation(),
+ cssAnimationStatus: this.cssAnimationStatus(),
+ attachDetachEvent: this.attachDetachEvent(),
+ state: this.state(),
+ canEmitOnOpenOrOnClosed: this.canEmitOnOpenOrOnClosed()
+ };
+ }
+
+ /** @ignore */
+ controlledExternally() {
+ return this.externalControl;
+ }
+
+ /** @ignore */
+ firstDefaultOpen() {
+ return this.isFirstDefaultOpen();
+ }
+
+ /** @ignore */
+ handleOpen(): void {
+ if (this.externalControl()) {
+ return;
+ }
+ this.setState(RdxPopoverState.OPEN);
+ }
+
+ /** @ignore */
+ handleClose(): void {
+ if (this.isFirstDefaultOpen()) {
+ this.isFirstDefaultOpen.set(false);
+ }
+ if (this.externalControl()) {
+ return;
+ }
+ this.setState(RdxPopoverState.CLOSED);
+ }
+
+ /** @ignore */
+ handleToggle(): void {
+ if (this.externalControl()) {
+ return;
+ }
+ this.isOpen() ? this.handleClose() : this.handleOpen();
+ }
+
+ /** @ignore */
+ isOpen(state?: RdxPopoverState) {
+ return (state ?? this.state()) === RdxPopoverState.OPEN;
+ }
+
+ /** @ignore */
+ private setState(state = RdxPopoverState.CLOSED): void {
+ if (state === this.state()) {
+ return;
+ }
+ this.state.set(state);
+ }
+
+ /** @ignore */
+ private openContent(): void {
+ this.popoverContentDirective().open();
+ if (!this.cssAnimation() || !this.cssOpeningAnimation()) {
+ this.cssAnimationStatus.set(null);
+ }
+ }
+
+ /** @ignore */
+ private closeContent(): void {
+ this.popoverContentDirective().close();
+ if (!this.cssAnimation() || !this.cssClosingAnimation()) {
+ this.cssAnimationStatus.set(null);
+ }
+ }
+
+ /** @ignore */
+ private emitOnOpen(): void {
+ this.popoverContentDirective().onOpen.emit();
+ }
+
+ /** @ignore */
+ private emitOnClosed(): void {
+ this.popoverContentDirective().onClosed.emit();
+ }
+
+ /** @ignore */
+ private ifOpenOrCloseWithoutAnimations(state: RdxPopoverState) {
+ return (
+ !this.popoverContentAttributesComponent() ||
+ !this.cssAnimation() ||
+ (this.cssAnimation() && !this.cssClosingAnimation() && state === RdxPopoverState.CLOSED) ||
+ (this.cssAnimation() && !this.cssOpeningAnimation() && state === RdxPopoverState.OPEN) ||
+ // !this.cssAnimationStatus() ||
+ (this.cssOpeningAnimation() &&
+ state === RdxPopoverState.OPEN &&
+ [RdxPopoverAnimationStatus.OPEN_STARTED].includes(this.cssAnimationStatus()!)) ||
+ (this.cssClosingAnimation() &&
+ state === RdxPopoverState.CLOSED &&
+ [RdxPopoverAnimationStatus.CLOSED_STARTED].includes(this.cssAnimationStatus()!))
+ );
+ }
+
+ /** @ignore */
+ private ifOpenOrCloseWithAnimations(cssAnimationStatus: RdxPopoverAnimationStatus | null) {
+ return (
+ this.popoverContentAttributesComponent() &&
+ this.cssAnimation() &&
+ cssAnimationStatus &&
+ ((this.cssOpeningAnimation() &&
+ this.state() === RdxPopoverState.OPEN &&
+ [RdxPopoverAnimationStatus.OPEN_ENDED].includes(cssAnimationStatus)) ||
+ (this.cssClosingAnimation() &&
+ this.state() === RdxPopoverState.CLOSED &&
+ [RdxPopoverAnimationStatus.CLOSED_ENDED].includes(cssAnimationStatus)))
+ );
+ }
+
+ /** @ignore */
+ private openOrClose(state: RdxPopoverState) {
+ const isOpen = this.isOpen(state);
+ isOpen ? this.openContent() : this.closeContent();
+ }
+
+ /** @ignore */
+ private emitOnOpenOrOnClosed(state: RdxPopoverState) {
+ this.isOpen(state)
+ ? this.attachDetachEvent() === RdxPopoverAttachDetachEvent.ATTACH && this.emitOnOpen()
+ : this.attachDetachEvent() === RdxPopoverAttachDetachEvent.DETACH && this.emitOnClosed();
+ }
+
+ /** @ignore */
+ private canEmitOnOpenOrOnClosed() {
+ return (
+ !this.cssAnimation() ||
+ (!this.cssOpeningAnimation() && this.state() === RdxPopoverState.OPEN) ||
+ (this.cssOpeningAnimation() &&
+ this.state() === RdxPopoverState.OPEN &&
+ this.cssAnimationStatus() === RdxPopoverAnimationStatus.OPEN_ENDED) ||
+ (!this.cssClosingAnimation() && this.state() === RdxPopoverState.CLOSED) ||
+ (this.cssClosingAnimation() &&
+ this.state() === RdxPopoverState.CLOSED &&
+ this.cssAnimationStatus() === RdxPopoverAnimationStatus.CLOSED_ENDED)
+ );
+ }
+
+ /** @ignore */
+ private onStateChangeEffect() {
+ let isFirst = true;
+ effect(() => {
+ const state = this.state();
+ untracked(() => {
+ if (isFirst) {
+ isFirst = false;
+ return;
+ }
+ if (!this.ifOpenOrCloseWithoutAnimations(state)) {
+ return;
+ }
+ this.openOrClose(state);
+ });
+ }, {});
+ }
+
+ /** @ignore */
+ private onCssAnimationStatusChangeChangeEffect() {
+ let isFirst = true;
+ effect(() => {
+ const cssAnimationStatus = this.cssAnimationStatus();
+ untracked(() => {
+ if (isFirst) {
+ isFirst = false;
+ return;
+ }
+ if (!this.ifOpenOrCloseWithAnimations(cssAnimationStatus)) {
+ return;
+ }
+ this.openOrClose(this.state());
+ });
+ });
+ }
+
+ /** @ignore */
+ private emitOpenOrClosedEventEffect() {
+ let isFirst = true;
+ effect(() => {
+ this.attachDetachEvent();
+ this.cssAnimationStatus();
+ untracked(() => {
+ if (isFirst) {
+ isFirst = false;
+ return;
+ }
+ const canEmitOpenClose = untracked(() => this.canEmitOnOpenOrOnClosed());
+ if (!canEmitOpenClose) {
+ return;
+ }
+ this.emitOnOpenOrOnClosed(this.state());
+ });
+ });
+ }
+
+ /** @ignore */
+ private onOpenChangeEffect() {
+ effect(() => {
+ const open = this.open();
+ untracked(() => {
+ this.setState(open ? RdxPopoverState.OPEN : RdxPopoverState.CLOSED);
+ });
+ });
+ }
+
+ /** @ignore */
+ private onIsFirstDefaultOpenChangeEffect() {
+ const effectRef = effect(() => {
+ const defaultOpen = this.defaultOpen();
+ untracked(() => {
+ if (!defaultOpen || this.open()) {
+ effectRef.destroy();
+ return;
+ }
+ this.handleOpen();
+ });
+ });
+ }
+
+ /** @ignore */
+ private onAnchorChangeEffect = () => {
+ effect(() => {
+ const anchor = this.anchor();
+ untracked(() => {
+ if (anchor) {
+ anchor.setPopoverRoot(this);
+ }
+ });
+ });
+ };
+}
diff --git a/packages/primitives/tooltip-v2/src/tooltip-root.inject.ts b/packages/primitives/tooltip-v2/src/tooltip-root.inject.ts
new file mode 100644
index 00000000..79291478
--- /dev/null
+++ b/packages/primitives/tooltip-v2/src/tooltip-root.inject.ts
@@ -0,0 +1,9 @@
+import { assertInInjectionContext, inject, isDevMode } from '@angular/core';
+import { RdxPopoverRootDirective } from './tooltip-root.directive';
+
+export function injectPopoverRoot(optional?: false): RdxPopoverRootDirective;
+export function injectPopoverRoot(optional: true): RdxPopoverRootDirective | null;
+export function injectPopoverRoot(optional = false): RdxPopoverRootDirective | null {
+ isDevMode() && assertInInjectionContext(injectPopoverRoot);
+ return inject(RdxPopoverRootDirective, { optional });
+}
diff --git a/packages/primitives/tooltip-v2/src/tooltip-trigger.directive.ts b/packages/primitives/tooltip-v2/src/tooltip-trigger.directive.ts
new file mode 100644
index 00000000..e8fa639f
--- /dev/null
+++ b/packages/primitives/tooltip-v2/src/tooltip-trigger.directive.ts
@@ -0,0 +1,33 @@
+import { CdkOverlayOrigin } from '@angular/cdk/overlay';
+import { computed, Directive, ElementRef, inject } from '@angular/core';
+import { injectPopoverRoot } from './tooltip-root.inject';
+
+@Directive({
+ selector: '[rdxPopoverTrigger]',
+ hostDirectives: [CdkOverlayOrigin],
+ host: {
+ type: 'button',
+ '[attr.id]': 'name()',
+ '[attr.aria-haspopup]': '"dialog"',
+ '[attr.aria-expanded]': 'popoverRoot.isOpen()',
+ '[attr.aria-controls]': 'popoverRoot.popoverContentDirective().name()',
+ '[attr.data-state]': 'popoverRoot.state()',
+ '(click)': 'click()'
+ }
+})
+export class RdxPopoverTriggerDirective {
+ /** @ignore */
+ protected readonly popoverRoot = injectPopoverRoot();
+ /** @ignore */
+ readonly elementRef = inject>(ElementRef);
+ /** @ignore */
+ readonly overlayOrigin = inject(CdkOverlayOrigin);
+
+ /** @ignore */
+ readonly name = computed(() => `rdx-popover-trigger-${this.popoverRoot.uniqueId()}`);
+
+ /** @ignore */
+ click(): void {
+ this.popoverRoot.handleToggle();
+ }
+}
diff --git a/packages/primitives/tooltip-v2/src/tooltip.types.ts b/packages/primitives/tooltip-v2/src/tooltip.types.ts
new file mode 100644
index 00000000..ccf53f28
--- /dev/null
+++ b/packages/primitives/tooltip-v2/src/tooltip.types.ts
@@ -0,0 +1,16 @@
+export enum RdxPopoverState {
+ OPEN = 'open',
+ CLOSED = 'closed'
+}
+
+export enum RdxPopoverAttachDetachEvent {
+ ATTACH = 'attach',
+ DETACH = 'detach'
+}
+
+export enum RdxPopoverAnimationStatus {
+ OPEN_STARTED = 'open_started',
+ OPEN_ENDED = 'open_ended',
+ CLOSED_STARTED = 'closed_started',
+ CLOSED_ENDED = 'closed_ended'
+}
diff --git a/packages/primitives/tooltip-v2/src/utils/cdk-event.service.ts b/packages/primitives/tooltip-v2/src/utils/cdk-event.service.ts
new file mode 100644
index 00000000..acf756b6
--- /dev/null
+++ b/packages/primitives/tooltip-v2/src/utils/cdk-event.service.ts
@@ -0,0 +1,203 @@
+import {
+ DestroyRef,
+ EnvironmentProviders,
+ inject,
+ Injectable,
+ InjectionToken,
+ isDevMode,
+ makeEnvironmentProviders,
+ NgZone,
+ Provider,
+ Renderer2,
+ VERSION
+} from '@angular/core';
+import { injectDocument, injectWindow } from '@radix-ng/primitives/core';
+import { RdxCdkEventServiceWindowKey } from './constants';
+import { EventType, EventTypeAsPrimitiveConfigKey, PrimitiveConfig, PrimitiveConfigs } from './types';
+
+function eventTypeAsPrimitiveConfigKey(eventType: EventType): EventTypeAsPrimitiveConfigKey {
+ return `prevent${eventType[0].toUpperCase()}${eventType.slice(1)}` as EventTypeAsPrimitiveConfigKey;
+}
+
+@Injectable()
+class RdxCdkEventService {
+ document = injectDocument();
+ destroyRef = inject(DestroyRef);
+ ngZone = inject(NgZone);
+ renderer2 = inject(Renderer2);
+ window = injectWindow();
+
+ primitiveConfigs?: PrimitiveConfigs;
+
+ onDestroyCallbacks: Set<() => void> = new Set([() => deleteRdxCdkEventServiceWindowKey(this.window)]);
+
+ #clickDomRootEventCallbacks: Set<(event: MouseEvent) => void> = new Set();
+
+ constructor() {
+ this.#listenToClickDomRootEvent();
+ this.#registerOnDestroyCallbacks();
+ }
+
+ registerPrimitive(primitiveInstance: T) {
+ if (!this.primitiveConfigs) {
+ this.primitiveConfigs = new Map();
+ }
+ if (!this.primitiveConfigs.has(primitiveInstance)) {
+ this.primitiveConfigs.set(primitiveInstance, {});
+ }
+ }
+
+ deregisterPrimitive(primitiveInstance: T) {
+ if (this.primitiveConfigs?.has(primitiveInstance)) {
+ this.primitiveConfigs.delete(primitiveInstance);
+ }
+ }
+
+ preventPrimitiveFromCdkEvent(primitiveInstance: T, eventType: EventType) {
+ this.#setPreventPrimitiveFromCdkEvent(primitiveInstance, eventType, true);
+ }
+
+ allowPrimitiveForCdkEvent(primitiveInstance: T, eventType: EventType) {
+ this.#setPreventPrimitiveFromCdkEvent(primitiveInstance, eventType, false);
+ }
+
+ preventPrimitiveFromCdkMultiEvents(primitiveInstance: T, eventTypes: EventType[]) {
+ eventTypes.forEach((eventType) => {
+ this.#setPreventPrimitiveFromCdkEvent(primitiveInstance, eventType, true);
+ });
+ }
+
+ allowPrimitiveForCdkMultiEvents(primitiveInstance: T, eventTypes: EventType[]) {
+ eventTypes.forEach((eventType) => {
+ this.#setPreventPrimitiveFromCdkEvent(primitiveInstance, eventType, false);
+ });
+ }
+
+ setPreventPrimitiveFromCdkMixEvents(primitiveInstance: T, eventTypes: PrimitiveConfig) {
+ Object.keys(eventTypes).forEach((eventType) => {
+ this.#setPreventPrimitiveFromCdkEvent(
+ primitiveInstance,
+ eventType as EventType,
+ eventTypes[eventTypeAsPrimitiveConfigKey(eventType as EventType)]
+ );
+ });
+ }
+
+ primitivePreventedFromCdkEvent(primitiveInstance: T, eventType: EventType) {
+ return this.primitiveConfigs?.get(primitiveInstance)?.[eventTypeAsPrimitiveConfigKey(eventType)];
+ }
+
+ addClickDomRootEventCallback(callback: (event: MouseEvent) => void) {
+ this.#clickDomRootEventCallbacks.add(callback);
+ }
+
+ removeClickDomRootEventCallback(callback: (event: MouseEvent) => void) {
+ return this.#clickDomRootEventCallbacks.delete(callback);
+ }
+
+ #setPreventPrimitiveFromCdkEvent<
+ T extends object,
+ R extends EventType,
+ K extends PrimitiveConfig[EventTypeAsPrimitiveConfigKey]
+ >(primitiveInstance: T, eventType: R, value: K) {
+ if (!this.primitiveConfigs?.has(primitiveInstance)) {
+ isDevMode() &&
+ console.error(
+ '[RdxCdkEventService.preventPrimitiveFromCdkEvent] RDX Primitive instance has not been registered!',
+ primitiveInstance
+ );
+ return;
+ }
+ switch (eventType) {
+ case 'cdkOverlayOutsideClick':
+ this.primitiveConfigs.get(primitiveInstance)!.preventCdkOverlayOutsideClick = value;
+ break;
+ case 'cdkOverlayEscapeKeyDown':
+ this.primitiveConfigs.get(primitiveInstance)!.preventCdkOverlayEscapeKeyDown = value;
+ break;
+ }
+ }
+
+ #registerOnDestroyCallbacks() {
+ this.destroyRef.onDestroy(() => {
+ this.onDestroyCallbacks.forEach((onDestroyCallback) => onDestroyCallback());
+ this.onDestroyCallbacks.clear();
+ });
+ }
+
+ #listenToClickDomRootEvent() {
+ const target = this.document;
+ const eventName = 'click';
+ const options: boolean | AddEventListenerOptions | undefined = { capture: true };
+ const callback = (event: MouseEvent) => {
+ this.#clickDomRootEventCallbacks.forEach((clickDomRootEventCallback) => clickDomRootEventCallback(event));
+ };
+
+ const major = parseInt(VERSION.major);
+ const minor = parseInt(VERSION.minor);
+
+ let destroyClickDomRootEventListener!: () => void;
+ /**
+ * @see src/cdk/platform/features/backwards-compatibility.ts in @angular/cdk
+ */
+ if (major > 19 || (major === 19 && minor > 0) || (major === 0 && minor === 0)) {
+ destroyClickDomRootEventListener = this.ngZone.runOutsideAngular(() => {
+ const destroyClickDomRootEventListenerInternal = this.renderer2.listen(
+ target,
+ eventName,
+ callback,
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ options
+ );
+ return () => {
+ destroyClickDomRootEventListenerInternal();
+ this.#clickDomRootEventCallbacks.clear();
+ };
+ });
+ } else {
+ /**
+ * This part can get removed when v19.1 or higher is on the board
+ */
+ destroyClickDomRootEventListener = this.ngZone.runOutsideAngular(() => {
+ target.addEventListener(eventName, callback, options);
+ return () => {
+ this.ngZone.runOutsideAngular(() => target.removeEventListener(eventName, callback, options));
+ this.#clickDomRootEventCallbacks.clear();
+ };
+ });
+ }
+ this.onDestroyCallbacks.add(destroyClickDomRootEventListener);
+ }
+}
+
+const RdxCdkEventServiceToken = new InjectionToken('RdxCdkEventServiceToken');
+
+const existsErrorMessage = 'RdxCdkEventService should be provided only once!';
+
+const deleteRdxCdkEventServiceWindowKey = (window: Window & typeof globalThis) => {
+ delete (window as any)[RdxCdkEventServiceWindowKey];
+};
+
+const getProvider: (throwWhenExists?: boolean) => Provider = (throwWhenExists = true) => ({
+ provide: RdxCdkEventServiceToken,
+ useFactory: () => {
+ isDevMode() && console.log('providing RdxCdkEventService...');
+ const window = injectWindow();
+ if ((window as any)[RdxCdkEventServiceWindowKey]) {
+ if (throwWhenExists) {
+ throw Error(existsErrorMessage);
+ } else {
+ isDevMode() && console.warn(existsErrorMessage);
+ }
+ }
+ (window as any)[RdxCdkEventServiceWindowKey] ??= new RdxCdkEventService();
+ return (window as any)[RdxCdkEventServiceWindowKey];
+ }
+});
+
+export const provideRdxCdkEventServiceInRoot: () => EnvironmentProviders = () =>
+ makeEnvironmentProviders([getProvider()]);
+export const provideRdxCdkEventService: () => Provider = () => getProvider(false);
+
+export const injectRdxCdkEventService = () => inject(RdxCdkEventServiceToken, { optional: true });
diff --git a/packages/primitives/tooltip-v2/src/utils/constants.ts b/packages/primitives/tooltip-v2/src/utils/constants.ts
new file mode 100644
index 00000000..3a81df29
--- /dev/null
+++ b/packages/primitives/tooltip-v2/src/utils/constants.ts
@@ -0,0 +1 @@
+export const RdxCdkEventServiceWindowKey = Symbol('__RdxCdkEventService__');
diff --git a/packages/primitives/tooltip-v2/src/utils/types.ts b/packages/primitives/tooltip-v2/src/utils/types.ts
new file mode 100644
index 00000000..4d1c2409
--- /dev/null
+++ b/packages/primitives/tooltip-v2/src/utils/types.ts
@@ -0,0 +1,7 @@
+export type EventType = 'cdkOverlayOutsideClick' | 'cdkOverlayEscapeKeyDown';
+export type EventTypeCapitalized = Capitalize;
+export type EventTypeAsPrimitiveConfigKey = `prevent${EventTypeCapitalized}`;
+export type PrimitiveConfig = {
+ [value in EventTypeAsPrimitiveConfigKey]?: boolean;
+};
+export type PrimitiveConfigs = Map
+
-
-