diff --git a/apps/app/src/app/pages/(components)/components/(toggle)/toggle.page.ts b/apps/app/src/app/pages/(components)/components/(toggle)/toggle.page.ts
index aff8660a1..7c12cbb9a 100644
--- a/apps/app/src/app/pages/(components)/components/(toggle)/toggle.page.ts
+++ b/apps/app/src/app/pages/(components)/components/(toggle)/toggle.page.ts
@@ -115,7 +115,7 @@ export const routeMeta: RouteMeta = {
-
+
diff --git a/apps/app/src/app/pages/(components)/components/(tooltip)/tooltip.page.ts b/apps/app/src/app/pages/(components)/components/(tooltip)/tooltip.page.ts
new file mode 100644
index 000000000..5f3ba2ca1
--- /dev/null
+++ b/apps/app/src/app/pages/(components)/components/(tooltip)/tooltip.page.ts
@@ -0,0 +1,83 @@
+import { RouteMeta } from '@analogjs/router';
+import { Component } from '@angular/core';
+import { CodePreviewDirective } from '../../../../shared/code/code-preview.directive';
+import { CodeComponent } from '../../../../shared/code/code.component';
+import { MainSectionDirective } from '../../../../shared/layout/main-section.directive';
+import { PageBottomNavPlaceholderComponent } from '../../../../shared/layout/page-bottom-nav-placeholder.component';
+import { PageBottomNavLinkComponent } from '../../../../shared/layout/page-bottom-nav/page-bottom-nav-link.component';
+import { PageBottomNavComponent } from '../../../../shared/layout/page-bottom-nav/page-bottom-nav.component';
+import { PageNavLinkComponent } from '../../../../shared/layout/page-nav/page-nav-link.component';
+import { PageNavComponent } from '../../../../shared/layout/page-nav/page-nav.component';
+import { SectionIntroComponent } from '../../../../shared/layout/section-intro.component';
+import { SectionSubHeadingComponent } from '../../../../shared/layout/section-sub-heading.component';
+import { TabsComponent } from '../../../../shared/layout/tabs.component';
+import { metaWith } from '../../../../shared/meta/meta.util';
+import { defaultCode, defaultImports, defaultSkeleton, TooltipPreviewComponent } from './tooltip.preview';
+
+export const routeMeta: RouteMeta = {
+ data: { breadcrumb: 'Tooltip' },
+ meta: metaWith(
+ 'spartan/ui - Tooltip',
+ 'A popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it.',
+ ),
+ title: 'spartan/ui - Tooltip',
+};
+@Component({
+ selector: 'spartan-tooltip',
+ standalone: true,
+ imports: [
+ MainSectionDirective,
+ CodeComponent,
+ SectionIntroComponent,
+ SectionSubHeadingComponent,
+ TabsComponent,
+ CodePreviewDirective,
+ PageNavLinkComponent,
+ PageNavComponent,
+ PageBottomNavComponent,
+ PageBottomNavLinkComponent,
+ TooltipPreviewComponent,
+ PageBottomNavPlaceholderComponent,
+ ],
+ template: `
+
+
+
+
+
+
+
+
+
+
+ Installation
+
+
+
+
+
+ Usage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+})
+export default class TogglePageComponent {
+ protected readonly defaultCode = defaultCode;
+ protected readonly defaultSkeleton = defaultSkeleton;
+ protected readonly defaultImports = defaultImports;
+}
diff --git a/apps/app/src/app/pages/(components)/components/(tooltip)/tooltip.preview.ts b/apps/app/src/app/pages/(components)/components/(tooltip)/tooltip.preview.ts
new file mode 100644
index 000000000..ad3a5af83
--- /dev/null
+++ b/apps/app/src/app/pages/(components)/components/(tooltip)/tooltip.preview.ts
@@ -0,0 +1,61 @@
+import { Component } from '@angular/core';
+import { radixPlus } from '@ng-icons/radix-icons';
+import { HlmButtonDirective } from '@spartan-ng/ui-button-helm';
+import { HlmIconComponent, provideIcons } from '@spartan-ng/ui-icon-helm';
+import { HlmTooltipDirective } from '@spartan-ng/ui-tooltip-helm';
+
+@Component({
+ selector: 'spartan-tooltip-preview',
+ standalone: true,
+ imports: [HlmButtonDirective, HlmTooltipDirective, HlmIconComponent],
+ providers: [provideIcons({ radixPlus })],
+ template: `
+
+
+
+
+
+
+ Add to library
+
+
+
+ `,
+})
+export class TooltipPreviewComponent {}
+
+export const defaultCode = `
+import { Component } from '@angular/core';
+import { radixPlus } from '@ng-icons/radix-icons';
+import { HlmButtonDirective } from '@spartan-ng/ui-button-helm';
+import { HlmIconComponent, provideIcons } from '@spartan-ng/ui-icon-helm';
+import { HlmTooltipDirective } from '@spartan-ng/ui-tooltip-helm';
+
+@Component({
+ selector: 'spartan-tooltip-preview',
+ standalone: true,
+ imports: [HlmButtonDirective, HlmTooltipDirective, HlmIconComponent],
+ providers: [provideIcons({ radixPlus })],
+ template: \`
+
+
+
+
+
+
+ Add to library
+
+
+
+ \`,
+})
+export class TooltipPreviewComponent {}
+`;
+
+export const defaultImports = `
+import { HlmTooltipDirective } from '@spartan-ng/ui-tooltip-helm';
+`;
+export const defaultSkeleton = `
+
+Add to library
+`;
diff --git a/apps/app/src/app/shared/layout/side-nav/side-nav-content.component.ts b/apps/app/src/app/shared/layout/side-nav/side-nav-content.component.ts
index 9c8eed222..f9a619f76 100644
--- a/apps/app/src/app/shared/layout/side-nav/side-nav-content.component.ts
+++ b/apps/app/src/app/shared/layout/side-nav/side-nav-content.component.ts
@@ -112,10 +112,7 @@ import { SideNavLinksComponent } from './side-nav-links.directive';
Toggle
-
- Tooltip
-
-
+ Tooltip
`,
diff --git a/libs/ui/tooltip/brain/.eslintrc.json b/libs/ui/tooltip/brain/.eslintrc.json
new file mode 100644
index 000000000..70093c623
--- /dev/null
+++ b/libs/ui/tooltip/brain/.eslintrc.json
@@ -0,0 +1,34 @@
+{
+ "extends": ["../../../../.eslintrc.json"],
+ "ignorePatterns": ["!**/*"],
+ "overrides": [
+ {
+ "files": ["*.ts"],
+ "rules": {
+ "@angular-eslint/no-host-metadata-property": 0,
+ "@angular-eslint/directive-selector": [
+ "error",
+ {
+ "type": "attribute",
+ "prefix": "brn",
+ "style": "camelCase"
+ }
+ ],
+ "@angular-eslint/component-selector": [
+ "error",
+ {
+ "type": "element",
+ "prefix": "brn",
+ "style": "kebab-case"
+ }
+ ]
+ },
+ "extends": ["plugin:@nx/angular", "plugin:@angular-eslint/template/process-inline-templates"]
+ },
+ {
+ "files": ["*.html"],
+ "extends": ["plugin:@nx/angular-template"],
+ "rules": {}
+ }
+ ]
+}
diff --git a/libs/ui/tooltip/brain/README.md b/libs/ui/tooltip/brain/README.md
new file mode 100644
index 000000000..0d16b2dae
--- /dev/null
+++ b/libs/ui/tooltip/brain/README.md
@@ -0,0 +1,7 @@
+# ui-tooltip-brain
+
+This library was generated with [Nx](https://nx.dev).
+
+## Running unit tests
+
+Run `nx test ui-tooltip-brain` to execute the unit tests.
diff --git a/libs/ui/tooltip/brain/jest.config.ts b/libs/ui/tooltip/brain/jest.config.ts
new file mode 100644
index 000000000..37f8d6387
--- /dev/null
+++ b/libs/ui/tooltip/brain/jest.config.ts
@@ -0,0 +1,21 @@
+/* eslint-disable */
+export default {
+ displayName: 'ui-tooltip-brain',
+ preset: '../../../../jest.preset.js',
+ setupFilesAfterEnv: ['/src/test-setup.ts'],
+ transform: {
+ '^.+\\.(ts|mjs|js|html)$': [
+ 'jest-preset-angular',
+ {
+ tsconfig: '/tsconfig.spec.json',
+ stringifyContentPathRegex: '\\.(html|svg)$',
+ },
+ ],
+ },
+ transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
+ snapshotSerializers: [
+ 'jest-preset-angular/build/serializers/no-ng-attributes',
+ 'jest-preset-angular/build/serializers/ng-snapshot',
+ 'jest-preset-angular/build/serializers/html-comment',
+ ],
+};
diff --git a/libs/ui/tooltip/brain/ng-package.json b/libs/ui/tooltip/brain/ng-package.json
new file mode 100644
index 000000000..cc469c4e8
--- /dev/null
+++ b/libs/ui/tooltip/brain/ng-package.json
@@ -0,0 +1,7 @@
+{
+ "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json",
+ "dest": "../../../../dist/libs/ui/tooltip/brain",
+ "lib": {
+ "entryFile": "src/index.ts"
+ }
+}
diff --git a/libs/ui/tooltip/brain/package.json b/libs/ui/tooltip/brain/package.json
new file mode 100644
index 000000000..1b0821a4a
--- /dev/null
+++ b/libs/ui/tooltip/brain/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "@spartan-ng/ui-tooltip-brain",
+ "version": "0.0.1-alpha.309",
+ "peerDependencies": {
+ "@angular/core": "17.0.2",
+ "@angular/cdk": "17.0.0",
+ "rxjs": "~7.8.0"
+ },
+ "dependencies": {},
+ "sideEffects": false,
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/libs/ui/tooltip/brain/project.json b/libs/ui/tooltip/brain/project.json
new file mode 100644
index 000000000..3e55b9700
--- /dev/null
+++ b/libs/ui/tooltip/brain/project.json
@@ -0,0 +1,58 @@
+{
+ "name": "ui-tooltip-brain",
+ "$schema": "../../../../node_modules/nx/schemas/project-schema.json",
+ "sourceRoot": "libs/ui/tooltip/brain/src",
+ "prefix": "brain",
+ "tags": ["scope:brain"],
+ "projectType": "library",
+ "targets": {
+ "build": {
+ "executor": "@nx/angular:package",
+ "outputs": ["{workspaceRoot}/dist/{projectRoot}"],
+ "options": {
+ "project": "libs/ui/tooltip/brain/ng-package.json"
+ },
+ "configurations": {
+ "production": {
+ "tsConfig": "libs/ui/tooltip/brain/tsconfig.lib.prod.json"
+ },
+ "development": {
+ "tsConfig": "libs/ui/tooltip/brain/tsconfig.lib.json"
+ }
+ },
+ "defaultConfiguration": "production"
+ },
+ "test": {
+ "executor": "@nx/jest:jest",
+ "outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
+ "options": {
+ "jestConfig": "libs/ui/tooltip/brain/jest.config.ts",
+ "passWithNoTests": true
+ },
+ "configurations": {
+ "ci": {
+ "ci": true,
+ "codeCoverage": true
+ }
+ }
+ },
+ "lint": {
+ "executor": "@nx/linter:eslint",
+ "outputs": ["{options.outputFile}"],
+ "options": {
+ "lintFilePatterns": [
+ "libs/ui/tooltip/brain/**/*.ts",
+ "libs/ui/tooltip/brain/**/*.html",
+ "libs/ui/tooltip/brain/package.json",
+ "libs/ui/tooltip/brain/project.json"
+ ]
+ }
+ },
+ "release": {
+ "executor": "@spartan-ng/tools:build-update-publish",
+ "options": {
+ "libName": "ui-tooltip-brain"
+ }
+ }
+ }
+}
diff --git a/libs/ui/tooltip/brain/src/index.ts b/libs/ui/tooltip/brain/src/index.ts
new file mode 100644
index 000000000..16b3ab3c1
--- /dev/null
+++ b/libs/ui/tooltip/brain/src/index.ts
@@ -0,0 +1,13 @@
+import { NgModule } from '@angular/core';
+import { BrnTooltipComponent } from './lib/brn-tooltip.component';
+import { BrnTooltipDirective } from './lib/brn-tooltip.directive';
+
+export * from './lib/brn-tooltip.component';
+export * from './lib/brn-tooltip.directive';
+export const BrnTooltipImports = [BrnTooltipDirective, BrnTooltipComponent] as const;
+
+@NgModule({
+ imports: [...BrnTooltipImports],
+ exports: [...BrnTooltipImports],
+})
+export class BrnTooltipModule {}
diff --git a/libs/ui/tooltip/brain/src/lib/brn-tooltip.component.ts b/libs/ui/tooltip/brain/src/lib/brn-tooltip.component.ts
new file mode 100644
index 000000000..3bd581e80
--- /dev/null
+++ b/libs/ui/tooltip/brain/src/lib/brn-tooltip.component.ts
@@ -0,0 +1,227 @@
+/**
+ * We are building on shoulders of giants here and adapt the implementation provided by the incredible Angular
+ * team: https://github.com/angular/components/blob/main/src/material/tooltip/tooltip.ts
+ * Check them out! Give them a try! Leave a star! Their work is incredible!
+ */
+
+import { isPlatformBrowser, NgTemplateOutlet } from '@angular/common';
+import {
+ ChangeDetectionStrategy,
+ ChangeDetectorRef,
+ Component,
+ ElementRef,
+ inject,
+ OnDestroy,
+ PLATFORM_ID,
+ Renderer2,
+ signal,
+ TemplateRef,
+ ViewChild,
+ ViewEncapsulation,
+} from '@angular/core';
+import { Subject } from 'rxjs';
+
+/**
+ * Internal component that wraps the tooltip's content.
+ * @docs-private
+ */
+@Component({
+ selector: 'brn-tooltip',
+ standalone: true,
+ template: `
+
+
+
+ `,
+ encapsulation: ViewEncapsulation.None,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: {
+ // Forces the element to have a layout in IE and Edge. This fixes issues where the element
+ // won't be rendered if the animations are disabled or there is no web animations polyfill.
+ '[style.zoom]': 'isVisible() ? 1 : null',
+ '(mouseleave)': '_handleMouseLeave($event)',
+ 'aria-hidden': 'true',
+ },
+ imports: [NgTemplateOutlet],
+})
+export class BrnTooltipComponent implements OnDestroy {
+ private readonly _cdr = inject(ChangeDetectorRef);
+ private readonly _isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
+ private readonly _renderer2 = inject(Renderer2);
+
+ protected readonly _contentHovered = signal(false);
+
+ public readonly _tooltipClasses = signal('');
+ public readonly side = signal('above');
+ /** Message to display in the tooltip */
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ public template: TemplateRef | null = null;
+
+ /** The timeout ID of any current timer set to show the tooltip */
+ private _showTimeoutId: ReturnType | undefined;
+ /** The timeout ID of any current timer set to hide the tooltip */
+ private _hideTimeoutId: ReturnType | undefined;
+ /** The timeout ID of any current timer set to animate the tooltip */
+ private _animateTimeoutId: ReturnType | undefined;
+
+ /** Element that caused the tooltip to open. */
+ public _triggerElement?: HTMLElement;
+
+ /** Amount of milliseconds to delay the closing sequence. */
+ public _mouseLeaveHideDelay = 0;
+ /** Amount of milliseconds of closing animation. */
+ public _exitAnimationDuration = 0;
+
+ /** Reference to the internal tooltip element. */
+ @ViewChild('tooltip', {
+ // Use a static query here since we interact directly with
+ // the DOM which can happen before `ngAfterViewInit`.
+ static: true,
+ })
+ _tooltip?: ElementRef;
+
+ /** Whether interactions on the page should close the tooltip */
+ private _closeOnInteraction = false;
+
+ /** Whether the tooltip is currently visible. */
+ private _isVisible = false;
+
+ /** Subject for notifying that the tooltip has been hidden from the view */
+ private readonly _onHide: Subject = new Subject();
+ public readonly afterHidden = this._onHide.asObservable();
+
+ /**
+ * Shows the tooltip with originating from the provided origin
+ * @param delay Amount of milliseconds to the delay showing the tooltip.
+ */
+ show(delay: number): void {
+ // Cancel the delayed hide if it is scheduled
+ if (this._hideTimeoutId != null) {
+ clearTimeout(this._hideTimeoutId);
+ }
+ if (this._animateTimeoutId != null) {
+ clearTimeout(this._animateTimeoutId);
+ }
+ this._showTimeoutId = setTimeout(() => {
+ this._toggleDataAttributes(true, this.side());
+ this._toggleVisibility(true);
+ this._showTimeoutId = undefined;
+ }, delay);
+ }
+
+ /**
+ * Begins to hide the tooltip after the provided delay in ms.
+ * @param delay Amount of milliseconds to delay hiding the tooltip.
+ * @param exitAnimationDuration Time before hiding to finish animation
+ * */
+ hide(delay: number, exitAnimationDuration: number): void {
+ // Cancel the delayed show if it is scheduled
+ if (this._showTimeoutId != null) {
+ clearTimeout(this._showTimeoutId);
+ }
+ // start out animation at delay minus animation delay or immediately if possible
+ this._animateTimeoutId = setTimeout(
+ () => {
+ this._animateTimeoutId = undefined;
+ if (this._contentHovered()) return;
+ this._toggleDataAttributes(false, this.side());
+ },
+ Math.max(delay, 0),
+ );
+ this._hideTimeoutId = setTimeout(() => {
+ this._hideTimeoutId = undefined;
+ if (this._contentHovered()) return;
+ this._toggleVisibility(false);
+ }, delay + exitAnimationDuration);
+ }
+
+ /** Whether the tooltip is being displayed. */
+ isVisible(): boolean {
+ return this._isVisible;
+ }
+
+ ngOnDestroy() {
+ this._cancelPendingAnimations();
+ this._onHide.complete();
+ this._triggerElement = undefined;
+ }
+
+ /**
+ * Interactions on the HTML body should close the tooltip immediately as defined in the
+ * material design spec.
+ * https://material.io/design/components/tooltips.html#behavior
+ */
+ _handleBodyInteraction(): void {
+ if (this._closeOnInteraction) {
+ this.hide(0, 0);
+ }
+ }
+
+ /**
+ * Marks that the tooltip needs to be checked in the next change detection run.
+ * Mainly used for rendering the initial text before positioning a tooltip, which
+ * can be problematic in components with OnPush change detection.
+ */
+ _markForCheck(): void {
+ this._cdr.markForCheck();
+ }
+
+ _handleMouseLeave({ relatedTarget }: MouseEvent) {
+ if (!relatedTarget || !this._triggerElement?.contains(relatedTarget as Node)) {
+ if (this.isVisible()) {
+ this.hide(this._mouseLeaveHideDelay, this._exitAnimationDuration);
+ } else {
+ this._finalize(false);
+ }
+ }
+ this._contentHovered.set(false);
+ }
+
+ /** Cancels any pending animation sequences. */
+ _cancelPendingAnimations() {
+ if (this._showTimeoutId != null) {
+ clearTimeout(this._showTimeoutId);
+ }
+
+ if (this._hideTimeoutId != null) {
+ clearTimeout(this._hideTimeoutId);
+ }
+
+ this._showTimeoutId = this._hideTimeoutId = undefined;
+ }
+
+ private _finalize(toVisible: boolean) {
+ if (toVisible) {
+ this._closeOnInteraction = true;
+ } else if (!this.isVisible()) {
+ this._onHide.next();
+ }
+ }
+
+ /** Toggles the visibility of the tooltip element. */
+ private _toggleVisibility(isVisible: boolean) {
+ // We set the classes directly here ourselves so that toggling the tooltip state
+ // isn't bound by change detection. This allows us to hide it even if the
+ // view ref has been detached from the CD tree.
+ const tooltip = this._tooltip?.nativeElement;
+ if (!tooltip || !this._isBrowser) return;
+ this._renderer2.setStyle(tooltip, 'visibility', isVisible ? 'visible' : 'hidden');
+ this._isVisible = isVisible;
+ }
+
+ private _toggleDataAttributes(isVisible: boolean, side: string) {
+ // We set the classes directly here ourselves so that toggling the tooltip state
+ // isn't bound by change detection. This allows us to hide it even if the
+ // view ref has been detached from the CD tree.
+ const tooltip = this._tooltip?.nativeElement;
+ if (!tooltip || !this._isBrowser) return;
+ this._renderer2.setAttribute(tooltip, 'data-side', side);
+ this._renderer2.setAttribute(tooltip, 'data-state', isVisible ? 'open' : 'closed');
+ }
+}
diff --git a/libs/ui/tooltip/brain/src/lib/brn-tooltip.directive.ts b/libs/ui/tooltip/brain/src/lib/brn-tooltip.directive.ts
new file mode 100644
index 000000000..2534496ca
--- /dev/null
+++ b/libs/ui/tooltip/brain/src/lib/brn-tooltip.directive.ts
@@ -0,0 +1,802 @@
+/**
+ * We are building on shoulders of giants here and adapt the implementation provided by the incredible Angular
+ * team: https://github.com/angular/components/blob/main/src/material/tooltip/tooltip.ts
+ * Check them out! Give them a try! Leave a star! Their work is incredible!
+ */
+
+/**
+ * @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.io/license
+ */
+import { AriaDescriber, FocusMonitor } from '@angular/cdk/a11y';
+import { Directionality } from '@angular/cdk/bidi';
+import { hasModifierKey } from '@angular/cdk/keycodes';
+import {
+ ConnectedPosition,
+ ConnectionPositionPair,
+ FlexibleConnectedPositionStrategy,
+ HorizontalConnectionPos,
+ OriginConnectionPosition,
+ Overlay,
+ OverlayConnectionPosition,
+ OverlayRef,
+ ScrollDispatcher,
+ ScrollStrategy,
+ VerticalConnectionPos,
+} from '@angular/cdk/overlay';
+import { Platform, normalizePassiveListenerOptions } from '@angular/cdk/platform';
+import { ComponentPortal } from '@angular/cdk/portal';
+import { DOCUMENT } from '@angular/common';
+import {
+ AfterViewInit,
+ Directive,
+ ElementRef,
+ InjectionToken,
+ Input,
+ NgZone,
+ OnDestroy,
+ TemplateRef,
+ ViewContainerRef,
+ booleanAttribute,
+ inject,
+ isDevMode,
+ numberAttribute,
+ signal,
+} from '@angular/core';
+import { Subject } from 'rxjs';
+import { take, takeUntil } from 'rxjs/operators';
+import { BrnTooltipComponent } from './brn-tooltip.component';
+
+export type TooltipPosition = 'left' | 'right' | 'above' | 'below' | 'before' | 'after';
+export type TooltipTouchGestures = 'auto' | 'on' | 'off';
+
+/** Time in ms to throttle repositioning after scroll events. */
+export const SCROLL_THROTTLE_MS = 20;
+
+export function getBrnTooltipInvalidPositionError(position: string) {
+ return Error(`Tooltip position "${position}" is invalid.`);
+}
+
+/** Injection token that determines the scroll handling while a tooltip is visible. */
+export const BRN_TOOLTIP_SCROLL_STRATEGY = new InjectionToken<() => ScrollStrategy>('brn-tooltip-scroll-strategy');
+export const BRN_TOOLTIP_SCROLL_STRATEGY_FACTORY_PROVIDER = {
+ provide: BRN_TOOLTIP_SCROLL_STRATEGY,
+ deps: [Overlay],
+ useFactory:
+ (overlay: Overlay): (() => ScrollStrategy) =>
+ () =>
+ overlay.scrollStrategies.reposition({ scrollThrottle: SCROLL_THROTTLE_MS }),
+};
+
+export function BRN_TOOLTIP_DEFAULT_OPTIONS_FACTORY(): BrnTooltipOptions {
+ return {
+ showDelay: 0,
+ hideDelay: 0,
+ touchendHideDelay: 1500,
+ };
+}
+
+export const BRN_TOOLTIP_DEFAULT_OPTIONS = new InjectionToken('mat-tooltip-default-options', {
+ providedIn: 'root',
+ factory: BRN_TOOLTIP_DEFAULT_OPTIONS_FACTORY,
+});
+
+export interface BrnTooltipOptions {
+ /** Default delay when the tooltip is shown. */
+ showDelay: number;
+ /** Default delay when the tooltip is hidden. */
+ hideDelay: number;
+ /** Default delay when hiding the tooltip on a touch device. */
+ touchendHideDelay: number;
+ /** Default touch gesture handling for tooltips. */
+ touchGestures?: TooltipTouchGestures;
+ /** Default position for tooltips. */
+ position?: TooltipPosition;
+ /**
+ * Default value for whether tooltips should be positioned near the click or touch origin
+ * instead of outside the element bounding box.
+ */
+ positionAtOrigin?: boolean;
+ /** Disables the ability for the user to interact with the tooltip element. */
+ disableTooltipInteractivity?: boolean;
+}
+
+const PANEL_CLASS = 'tooltip-panel';
+
+/** Options used to bind passive event listeners. */
+const passiveListenerOptions = normalizePassiveListenerOptions({ passive: true });
+
+/**
+ * Time between the user putting the pointer on a tooltip
+ * trigger and the long press event being fired.
+ */
+const LONGPRESS_DELAY = 500;
+
+// These constants were taken from MDC's `numbers` object.
+const MIN_VIEWPORT_TOOLTIP_THRESHOLD = 8;
+const UNBOUNDED_ANCHOR_GAP = 8;
+
+@Directive({
+ selector: '[brnTooltip]',
+ standalone: true,
+ exportAs: 'brnTooltip',
+ providers: [BRN_TOOLTIP_SCROLL_STRATEGY_FACTORY_PROVIDER],
+ host: {
+ class: 'brn-tooltip-trigger',
+ '[class.brn-tooltip-disabled]': 'disabled',
+ },
+})
+export class BrnTooltipDirective implements OnDestroy, AfterViewInit {
+ private readonly _tooltipComponent = BrnTooltipComponent;
+ private readonly _cssClassPrefix: string = 'brn';
+ private readonly _destroyed = new Subject();
+ private readonly _passiveListeners: (readonly [string, EventListenerOrEventListenerObject])[] = [];
+ private readonly _defaultOptions = inject(BRN_TOOLTIP_DEFAULT_OPTIONS, { optional: true });
+ private readonly _overlay = inject(Overlay);
+ private readonly _elementRef = inject(ElementRef);
+ private readonly _scrollDispatcher = inject(ScrollDispatcher);
+ private readonly _viewContainerRef = inject(ViewContainerRef);
+ private readonly _ngZone = inject(NgZone);
+ private readonly _platform = inject(Platform);
+ private readonly _ariaDescriber = inject(AriaDescriber);
+ private readonly _focusMonitor = inject(FocusMonitor);
+ private readonly _dir = inject(Directionality);
+ private readonly _scrollStrategy = inject(BRN_TOOLTIP_SCROLL_STRATEGY);
+ private readonly _document = inject(DOCUMENT);
+
+ private _portal?: ComponentPortal;
+ private _viewInitialized = false;
+ private _pointerExitEventsInitialized = false;
+ private _viewportMargin = 8;
+ private _currentPosition?: TooltipPosition;
+ private _touchstartTimeout?: ReturnType;
+
+ private _overlayRef: OverlayRef | null = null;
+ private _tooltipInstance: BrnTooltipComponent | null = null;
+
+ /** Allows the user to define the position of the tooltip relative to the parent element */
+ private _position = signal('above');
+ @Input()
+ get position(): TooltipPosition {
+ return this._position();
+ }
+
+ set position(value: TooltipPosition) {
+ if (value !== this._position()) {
+ this._position.set(value);
+
+ if (this._overlayRef) {
+ this._updatePosition(this._overlayRef);
+ this._tooltipInstance?.show(0);
+ this._overlayRef.updatePosition();
+ }
+ }
+ }
+
+ /**
+ * Whether tooltip should be relative to the click or touch origin
+ * instead of outside the element bounding box.
+ */
+ private readonly _positionAtOrigin = signal(false);
+ @Input({ transform: booleanAttribute })
+ get positionAtOrigin(): boolean {
+ return this._positionAtOrigin();
+ }
+
+ set positionAtOrigin(value: boolean) {
+ this._positionAtOrigin.set(value);
+ this._detach();
+ this._overlayRef = null;
+ }
+
+ /** Disables the display of the tooltip. */
+ private _disabled = signal(false);
+ @Input({ transform: booleanAttribute })
+ get disabled(): boolean {
+ return this._disabled();
+ }
+
+ set disabled(value: boolean) {
+ this._disabled.set(value);
+
+ // If tooltip is disabled, hide immediately.
+ if (value) {
+ this.hide(0);
+ } else {
+ this._setupPointerEnterEventsIfNeeded();
+ }
+ }
+
+ /** The default delay in ms before showing the tooltip after show is called */
+ private _showDelay = signal(0);
+ @Input({ transform: numberAttribute })
+ get showDelay(): number {
+ return this._showDelay();
+ }
+
+ set showDelay(value: number) {
+ this._showDelay.set(value);
+ }
+
+ /** The default delay in ms before hiding the tooltip after hide is called */
+ private _hideDelay = signal(0);
+ @Input({ transform: numberAttribute })
+ get hideDelay(): number {
+ return this._hideDelay();
+ }
+
+ set hideDelay(value: number) {
+ this._hideDelay.set(value);
+
+ if (this._tooltipInstance) {
+ this._tooltipInstance._mouseLeaveHideDelay = this._hideDelay();
+ }
+ }
+
+ /** The default duration in ms that exit animation takes before hiding */
+ private _exitAnimationDuration = signal(0);
+ @Input({ transform: numberAttribute })
+ get exitAnimationDuration(): number {
+ return this._exitAnimationDuration();
+ }
+
+ set exitAnimationDuration(value: number) {
+ this._exitAnimationDuration.set(value);
+
+ if (this._tooltipInstance) {
+ this._tooltipInstance._exitAnimationDuration = this._exitAnimationDuration();
+ }
+ }
+
+ /** The default delay in ms before hiding the tooltip after hide is called */
+ private _tooltipContentClasses = signal('');
+ @Input()
+ get tooltipContentClasses(): string {
+ return this._tooltipContentClasses();
+ }
+
+ set tooltipContentClasses(value: string | null | undefined) {
+ this._tooltipContentClasses.set(value ?? '');
+
+ if (this._tooltipInstance) {
+ this._tooltipInstance._tooltipClasses.set(value ?? '');
+ }
+ }
+
+ /**
+ * How touch gestures should be handled by the tooltip. On touch devices the tooltip directive
+ * uses a long press gesture to show and hide, however it can conflict with the native browser
+ * gestures. To work around the conflict, Angular Material disables native gestures on the
+ * trigger, but that might not be desirable on particular elements (e.g. inputs and draggable
+ * elements). The different values for this option configure the touch event handling as follows:
+ * - `auto` - Enables touch gestures for all elements, but tries to avoid conflicts with native
+ * browser gestures on particular elements. In particular, it allows text selection on inputs
+ * and textareas, and preserves the native browser dragging on elements marked as `draggable`.
+ * - `on` - Enables touch gestures for all elements and disables native
+ * browser gestures with no exceptions.
+ * - `off` - Disables touch gestures. Note that this will prevent the tooltip from
+ * showing on touch devices.
+ */
+ private _touchGestures = signal('auto');
+ @Input()
+ set touchGestures(value: TooltipTouchGestures) {
+ this._touchGestures.set(value);
+ }
+
+ get touchGestures() {
+ return this._touchGestures();
+ }
+
+ /** The message to be used to describe the aria in the tooltip */
+ private _ariaDescribedBy = '';
+ @Input('aria-describedby')
+ get ariaDescribedBy() {
+ return this._ariaDescribedBy;
+ }
+
+ set ariaDescribedBy(value: string) {
+ this._ariaDescriber.removeDescription(this._elementRef.nativeElement, this._ariaDescribedBy, 'tooltip');
+
+ // If the message is not a string (e.g. number), convert it to a string and trim it.
+ // Must convert with `String(value)`, not `${value}`, otherwise Closure Compiler optimises
+ // away the string-conversion: https://github.com/angular/components/issues/20684
+ this._ariaDescribedBy = value != null ? String(value).trim() : '';
+
+ if (this._ariaDescribedBy && !this._isTooltipVisible()) {
+ this._ngZone.runOutsideAngular(() => {
+ // The `AriaDescriber` has some functionality that avoids adding a description if it's the
+ // same as the `aria-label` of an element, however we can't know whether the tooltip trigger
+ // has a data-bound `aria-label` or when it'll be set for the first time. We can avoid the
+ // issue by deferring the description by a tick so Angular has time to set the `aria-label`.
+ Promise.resolve().then(() => {
+ this._ariaDescriber.describe(this._elementRef.nativeElement, this._ariaDescribedBy, 'tooltip');
+ });
+ });
+ }
+ }
+
+ /** The content to be displayed in the tooltip */
+ private _content: TemplateRef | null = null;
+ @Input('brnTooltip')
+ get content() {
+ return this._content;
+ }
+
+ set content(value: TemplateRef | null) {
+ this._content = value;
+
+ if (!this._content && this._isTooltipVisible()) {
+ this.hide(0);
+ } else {
+ this._setupPointerEnterEventsIfNeeded();
+ this._updateTooltipContent();
+ }
+ }
+
+ constructor() {
+ if (this._defaultOptions) {
+ this._showDelay.set(this._defaultOptions.showDelay);
+ this._hideDelay.set(this._defaultOptions.hideDelay);
+
+ if (this._defaultOptions.position) {
+ this.position = this._defaultOptions.position;
+ }
+
+ if (this._defaultOptions.positionAtOrigin) {
+ this.positionAtOrigin = this._defaultOptions.positionAtOrigin;
+ }
+
+ if (this._defaultOptions.touchGestures) {
+ this.touchGestures = this._defaultOptions.touchGestures;
+ }
+ }
+
+ this._dir.change.pipe(takeUntil(this._destroyed)).subscribe(() => {
+ if (this._overlayRef) {
+ this._updatePosition(this._overlayRef);
+ }
+ });
+
+ this._viewportMargin = MIN_VIEWPORT_TOOLTIP_THRESHOLD;
+ }
+
+ ngAfterViewInit() {
+ // This needs to happen after view init so the initial values for all inputs have been set.
+ this._viewInitialized = true;
+ this._setupPointerEnterEventsIfNeeded();
+
+ this._focusMonitor
+ .monitor(this._elementRef)
+ .pipe(takeUntil(this._destroyed))
+ .subscribe((origin) => {
+ // Note that the focus monitor runs outside the Angular zone.
+ if (!origin) {
+ this._ngZone.run(() => this.hide(0));
+ } else if (origin === 'keyboard') {
+ this._ngZone.run(() => this.show());
+ }
+ });
+ }
+
+ /**
+ * Dispose the tooltip when destroyed.
+ */
+ ngOnDestroy() {
+ const nativeElement = this._elementRef.nativeElement;
+
+ clearTimeout(this._touchstartTimeout);
+
+ if (this._overlayRef) {
+ this._overlayRef.dispose();
+ this._tooltipInstance = null;
+ }
+
+ // Clean up the event listeners set in the constructor
+ this._passiveListeners.forEach(([event, listener]) =>
+ nativeElement.removeEventListener(event, listener, passiveListenerOptions),
+ );
+ this._passiveListeners.length = 0;
+
+ this._destroyed.next();
+ this._destroyed.complete();
+
+ this._ariaDescriber.removeDescription(nativeElement, this._ariaDescribedBy, 'tooltip');
+ this._focusMonitor.stopMonitoring(nativeElement);
+ }
+
+ /** Shows the tooltip after the delay in ms, defaults to tooltip-delay-show or 0ms if no input */
+ show(delay: number = this.showDelay, origin?: { x: number; y: number }): void {
+ if (this.disabled || !this._ariaDescribedBy || this._isTooltipVisible()) {
+ this._tooltipInstance?._cancelPendingAnimations();
+ return;
+ }
+
+ const overlayRef = this._createOverlay(origin);
+ this._detach();
+ this._portal = this._portal || new ComponentPortal(this._tooltipComponent, this._viewContainerRef);
+ const instance = (this._tooltipInstance = overlayRef.attach(this._portal).instance);
+ instance._triggerElement = this._elementRef.nativeElement;
+ instance._mouseLeaveHideDelay = this._hideDelay();
+ instance._tooltipClasses.set(this._tooltipContentClasses());
+ instance._exitAnimationDuration = this._exitAnimationDuration();
+ instance.side.set(this._currentPosition ?? 'above');
+ instance.afterHidden.pipe(takeUntil(this._destroyed)).subscribe(() => this._detach());
+ this._updateTooltipContent();
+ instance.show(delay);
+ }
+
+ /** Hides the tooltip after the delay in ms, defaults to tooltip-delay-hide or 0ms if no input */
+ hide(delay: number = this.hideDelay, exitAnimationDuration: number = this.exitAnimationDuration): void {
+ const instance = this._tooltipInstance;
+ if (instance) {
+ if (instance.isVisible()) {
+ instance.hide(delay, exitAnimationDuration);
+ } else {
+ instance._cancelPendingAnimations();
+ this._detach();
+ }
+ }
+ }
+
+ toggle(origin?: { x: number; y: number }): void {
+ this._isTooltipVisible() ? this.hide() : this.show(undefined, origin);
+ }
+
+ _isTooltipVisible(): boolean {
+ return !!this._tooltipInstance && this._tooltipInstance.isVisible();
+ }
+
+ private _createOverlay(origin?: { x: number; y: number }): OverlayRef {
+ if (this._overlayRef) {
+ const existingStrategy = this._overlayRef.getConfig().positionStrategy as FlexibleConnectedPositionStrategy;
+
+ if ((!this.positionAtOrigin || !origin) && existingStrategy._origin instanceof ElementRef) {
+ return this._overlayRef;
+ }
+
+ this._detach();
+ }
+
+ const scrollableAncestors = this._scrollDispatcher.getAncestorScrollContainers(this._elementRef);
+
+ // Create connected position strategy that listens for scroll events to reposition.
+ const strategy = this._overlay
+ .position()
+ .flexibleConnectedTo(this.positionAtOrigin ? origin || this._elementRef : this._elementRef)
+ .withTransformOriginOn(`.${this._cssClassPrefix}-tooltip`)
+ .withFlexibleDimensions(false)
+ .withViewportMargin(this._viewportMargin)
+ .withScrollableContainers(scrollableAncestors);
+
+ strategy.positionChanges.pipe(takeUntil(this._destroyed)).subscribe((change) => {
+ this._updateCurrentPositionClass(change.connectionPair);
+
+ if (this._tooltipInstance) {
+ if (change.scrollableViewProperties.isOverlayClipped && this._tooltipInstance.isVisible()) {
+ // After position changes occur and the overlay is clipped by
+ // a parent scrollable then close the tooltip.
+ this._ngZone.run(() => this.hide(0));
+ }
+ }
+ });
+
+ this._overlayRef = this._overlay.create({
+ direction: this._dir,
+ positionStrategy: strategy,
+ panelClass: `${this._cssClassPrefix}-${PANEL_CLASS}`,
+ scrollStrategy: this._scrollStrategy(),
+ });
+
+ this._updatePosition(this._overlayRef);
+
+ this._overlayRef
+ .detachments()
+ .pipe(takeUntil(this._destroyed))
+ .subscribe(() => this._detach());
+
+ this._overlayRef
+ .outsidePointerEvents()
+ .pipe(takeUntil(this._destroyed))
+ .subscribe(() => this._tooltipInstance?._handleBodyInteraction());
+
+ this._overlayRef
+ .keydownEvents()
+ .pipe(takeUntil(this._destroyed))
+ .subscribe((event) => {
+ if (this._isTooltipVisible() && event.key === 'Escape' && !hasModifierKey(event)) {
+ event.preventDefault();
+ event.stopPropagation();
+ this._ngZone.run(() => this.hide(0));
+ }
+ });
+
+ if (this._defaultOptions?.disableTooltipInteractivity) {
+ this._overlayRef.addPanelClass(`${this._cssClassPrefix}-tooltip-panel-non-interactive`);
+ }
+
+ return this._overlayRef;
+ }
+
+ private _detach() {
+ if (this._overlayRef && this._overlayRef.hasAttached()) {
+ this._overlayRef.detach();
+ }
+
+ this._tooltipInstance = null;
+ }
+
+ private _updatePosition(overlayRef: OverlayRef) {
+ const position = overlayRef.getConfig().positionStrategy as FlexibleConnectedPositionStrategy;
+ const origin = this._getOrigin();
+ const overlay = this._getOverlayPosition();
+
+ position.withPositions([
+ this._addOffset({ ...origin.main, ...overlay.main }),
+ this._addOffset({ ...origin.fallback, ...overlay.fallback }),
+ ]);
+ }
+
+ /** Adds the configured offset to a position. Used as a hook for child classes. */
+ protected _addOffset(position: ConnectedPosition): ConnectedPosition {
+ const offset = UNBOUNDED_ANCHOR_GAP;
+ const isLtr = !this._dir || this._dir.value == 'ltr';
+
+ if (position.originY === 'top') {
+ position.offsetY = -offset;
+ } else if (position.originY === 'bottom') {
+ position.offsetY = offset;
+ } else if (position.originX === 'start') {
+ position.offsetX = isLtr ? -offset : offset;
+ } else if (position.originX === 'end') {
+ position.offsetX = isLtr ? offset : -offset;
+ }
+
+ return position;
+ }
+
+ /**
+ * Returns the origin position and a fallback position based on the user's position preference.
+ * The fallback position is the inverse of the origin (e.g. `'below' -> 'above'`).
+ */
+ _getOrigin(): { main: OriginConnectionPosition; fallback: OriginConnectionPosition } {
+ const isLtr = !this._dir || this._dir.value == 'ltr';
+ const position = this.position;
+ let originPosition: OriginConnectionPosition;
+
+ if (position == 'above' || position == 'below') {
+ originPosition = { originX: 'center', originY: position == 'above' ? 'top' : 'bottom' };
+ } else if (position == 'before' || (position == 'left' && isLtr) || (position == 'right' && !isLtr)) {
+ originPosition = { originX: 'start', originY: 'center' };
+ } else if (position == 'after' || (position == 'right' && isLtr) || (position == 'left' && !isLtr)) {
+ originPosition = { originX: 'end', originY: 'center' };
+ } else if (typeof isDevMode() === 'undefined' || isDevMode()) {
+ throw getBrnTooltipInvalidPositionError(position);
+ }
+
+ const { x, y } = this._invertPosition(originPosition!.originX, originPosition!.originY);
+
+ return {
+ main: originPosition!,
+ fallback: { originX: x, originY: y },
+ };
+ }
+
+ /** Returns the overlay position and a fallback position based on the user's preference */
+ _getOverlayPosition(): { main: OverlayConnectionPosition; fallback: OverlayConnectionPosition } {
+ const isLtr = !this._dir || this._dir.value == 'ltr';
+ const position = this.position;
+ let overlayPosition: OverlayConnectionPosition;
+
+ if (position == 'above') {
+ overlayPosition = { overlayX: 'center', overlayY: 'bottom' };
+ } else if (position == 'below') {
+ overlayPosition = { overlayX: 'center', overlayY: 'top' };
+ } else if (position == 'before' || (position == 'left' && isLtr) || (position == 'right' && !isLtr)) {
+ overlayPosition = { overlayX: 'end', overlayY: 'center' };
+ } else if (position == 'after' || (position == 'right' && isLtr) || (position == 'left' && !isLtr)) {
+ overlayPosition = { overlayX: 'start', overlayY: 'center' };
+ } else if (typeof isDevMode() === 'undefined' || isDevMode()) {
+ throw getBrnTooltipInvalidPositionError(position);
+ }
+
+ const { x, y } = this._invertPosition(overlayPosition!.overlayX, overlayPosition!.overlayY);
+
+ return {
+ main: overlayPosition!,
+ fallback: { overlayX: x, overlayY: y },
+ };
+ }
+
+ /** Updates the tooltip message and repositions the overlay according to the new message length */
+ private _updateTooltipContent() {
+ // Must wait for the template to be painted to the tooltip so that the overlay can properly
+ // calculate the correct positioning based on the size of the tek-pate.
+ if (this._tooltipInstance) {
+ this._tooltipInstance.template = this.content;
+ this._tooltipInstance._markForCheck();
+
+ this._ngZone.onMicrotaskEmpty.pipe(take(1), takeUntil(this._destroyed)).subscribe(() => {
+ if (this._tooltipInstance) {
+ this._overlayRef?.updatePosition();
+ }
+ });
+ }
+ }
+
+ /** Inverts an overlay position. */
+ private _invertPosition(x: HorizontalConnectionPos, y: VerticalConnectionPos) {
+ if (this.position === 'above' || this.position === 'below') {
+ if (y === 'top') {
+ y = 'bottom';
+ } else if (y === 'bottom') {
+ y = 'top';
+ }
+ } else {
+ if (x === 'end') {
+ x = 'start';
+ } else if (x === 'start') {
+ x = 'end';
+ }
+ }
+
+ return { x, y };
+ }
+
+ /** Updates the class on the overlay panel based on the current position of the tooltip. */
+ private _updateCurrentPositionClass(connectionPair: ConnectionPositionPair): void {
+ const { overlayY, originX, originY } = connectionPair;
+ let newPosition: TooltipPosition;
+
+ // If the overlay is in the middle along the Y axis,
+ // it means that it's either before or after.
+ if (overlayY === 'center') {
+ // Note that since this information is used for styling, we want to
+ // resolve `start` and `end` to their real values, otherwise consumers
+ // would have to remember to do it themselves on each consumption.
+ if (this._dir && this._dir.value === 'rtl') {
+ newPosition = originX === 'end' ? 'left' : 'right';
+ } else {
+ newPosition = originX === 'start' ? 'left' : 'right';
+ }
+ } else {
+ newPosition = overlayY === 'bottom' && originY === 'top' ? 'above' : 'below';
+ }
+
+ if (newPosition !== this._currentPosition) {
+ this._tooltipInstance?.side.set(newPosition);
+ this._currentPosition = newPosition;
+ }
+ }
+
+ /** Binds the pointer events to the tooltip trigger. */
+ private _setupPointerEnterEventsIfNeeded() {
+ // Optimization: Defer hooking up events if there's no content or the tooltip is disabled.
+ if (this._disabled() || !this.content || !this._viewInitialized || this._passiveListeners.length) {
+ return;
+ }
+
+ // The mouse events shouldn't be bound on mobile devices, because they can prevent the
+ // first tap from firing its click event or can cause the tooltip to open for clicks.
+ if (this._platformSupportsMouseEvents()) {
+ this._passiveListeners.push([
+ 'mouseenter',
+ (event) => {
+ this._setupPointerExitEventsIfNeeded();
+ let point = undefined;
+ if ((event as MouseEvent).x !== undefined && (event as MouseEvent).y !== undefined) {
+ point = event as MouseEvent;
+ }
+ this.show(undefined, point);
+ },
+ ]);
+ } else if (this.touchGestures !== 'off') {
+ this._disableNativeGesturesIfNecessary();
+
+ this._passiveListeners.push([
+ 'touchstart',
+ (event) => {
+ const touch = (event as TouchEvent).targetTouches?.[0];
+ const origin = touch ? { x: touch.clientX, y: touch.clientY } : undefined;
+ // Note that it's important that we don't `preventDefault` here,
+ // because it can prevent click events from firing on the element.
+ this._setupPointerExitEventsIfNeeded();
+ clearTimeout(this._touchstartTimeout);
+ this._touchstartTimeout = setTimeout(() => this.show(undefined, origin), LONGPRESS_DELAY);
+ },
+ ]);
+ }
+
+ this._addListeners(this._passiveListeners);
+ }
+
+ private _setupPointerExitEventsIfNeeded() {
+ if (this._pointerExitEventsInitialized) {
+ return;
+ }
+ this._pointerExitEventsInitialized = true;
+
+ const exitListeners: (readonly [string, EventListenerOrEventListenerObject])[] = [];
+ if (this._platformSupportsMouseEvents()) {
+ exitListeners.push(
+ [
+ 'mouseleave',
+ (event) => {
+ const newTarget = (event as MouseEvent).relatedTarget as Node | null;
+ if (!newTarget || !this._overlayRef?.overlayElement.contains(newTarget)) {
+ this.hide();
+ }
+ },
+ ],
+ ['wheel', (event) => this._wheelListener(event as WheelEvent)],
+ );
+ } else if (this.touchGestures !== 'off') {
+ this._disableNativeGesturesIfNecessary();
+ const touchendListener = () => {
+ clearTimeout(this._touchstartTimeout);
+ this.hide(this._defaultOptions?.touchendHideDelay);
+ };
+
+ exitListeners.push(['touchend', touchendListener], ['touchcancel', touchendListener]);
+ }
+
+ this._addListeners(exitListeners);
+ this._passiveListeners.push(...exitListeners);
+ }
+
+ private _addListeners(listeners: (readonly [string, EventListenerOrEventListenerObject])[]) {
+ listeners.forEach(([event, listener]) => {
+ this._elementRef.nativeElement.addEventListener(event, listener, passiveListenerOptions);
+ });
+ }
+
+ private _platformSupportsMouseEvents() {
+ return !this._platform.IOS && !this._platform.ANDROID;
+ }
+
+ /** Listener for the `wheel` event on the element. */
+ private _wheelListener(event: WheelEvent) {
+ if (this._isTooltipVisible()) {
+ const elementUnderPointer = this._document.elementFromPoint(event.clientX, event.clientY);
+ const element = this._elementRef.nativeElement;
+
+ // On non-touch devices we depend on the `mouseleave` event to close the tooltip, but it
+ // won't fire if the user scrolls away using the wheel without moving their cursor. We
+ // work around it by finding the element under the user's cursor and closing the tooltip
+ // if it's not the trigger.
+ if (elementUnderPointer !== element && !element.contains(elementUnderPointer)) {
+ this.hide();
+ }
+ }
+ }
+
+ /** Disables the native browser gestures, based on how the tooltip has been configured. */
+ private _disableNativeGesturesIfNecessary() {
+ const gestures = this.touchGestures;
+
+ if (gestures !== 'off') {
+ const element = this._elementRef.nativeElement;
+ const style = element.style;
+
+ // If gestures are set to `auto`, we don't disable text selection on inputs and
+ // textareas, because it prevents the user from typing into them on iOS Safari.
+ if (gestures === 'on' || (element.nodeName !== 'INPUT' && element.nodeName !== 'TEXTAREA')) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ style.userSelect = (style as any).msUserSelect = style.webkitUserSelect = (style as any).MozUserSelect = 'none';
+ }
+
+ // If we have `auto` gestures and the element uses native HTML dragging,
+ // we don't set `-webkit-user-drag` because it prevents the native behavior.
+ if (gestures === 'on' || !element.draggable) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (style as any).webkitUserDrag = 'none';
+ }
+
+ style.touchAction = 'none';
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (style as any).webkitTapHighlightColor = 'transparent';
+ }
+ }
+}
diff --git a/libs/ui/tooltip/brain/src/test-setup.ts b/libs/ui/tooltip/brain/src/test-setup.ts
new file mode 100644
index 000000000..9136c4aec
--- /dev/null
+++ b/libs/ui/tooltip/brain/src/test-setup.ts
@@ -0,0 +1,13 @@
+// @ts-expect-error https://thymikee.github.io/jest-preset-angular/docs/getting-started/test-environment
+globalThis.ngJest = {
+ testEnvironmentOptions: {
+ errorOnUnknownElements: true,
+ errorOnUnknownProperties: true,
+ },
+};
+import '@testing-library/jest-dom';
+import 'jest-preset-angular/setup-jest';
+
+import { toHaveNoViolations } from 'jest-axe';
+
+expect.extend(toHaveNoViolations);
diff --git a/libs/ui/tooltip/brain/tsconfig.json b/libs/ui/tooltip/brain/tsconfig.json
new file mode 100644
index 000000000..652fa49ce
--- /dev/null
+++ b/libs/ui/tooltip/brain/tsconfig.json
@@ -0,0 +1,29 @@
+{
+ "compilerOptions": {
+ "target": "es2022",
+ "useDefineForClassFields": false,
+ "forceConsistentCasingInFileNames": true,
+ "strict": true,
+ "noImplicitOverride": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "files": [],
+ "include": [],
+ "references": [
+ {
+ "path": "./tsconfig.lib.json"
+ },
+ {
+ "path": "./tsconfig.spec.json"
+ }
+ ],
+ "extends": "../../../../tsconfig.base.json",
+ "angularCompilerOptions": {
+ "enableI18nLegacyMessageIdFormat": false,
+ "strictInjectionParameters": true,
+ "strictInputAccessModifiers": true,
+ "strictTemplates": true
+ }
+}
diff --git a/libs/ui/tooltip/brain/tsconfig.lib.json b/libs/ui/tooltip/brain/tsconfig.lib.json
new file mode 100644
index 000000000..e82bb223c
--- /dev/null
+++ b/libs/ui/tooltip/brain/tsconfig.lib.json
@@ -0,0 +1,12 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../../../dist/out-tsc",
+ "declaration": true,
+ "declarationMap": true,
+ "inlineSources": true,
+ "types": []
+ },
+ "exclude": ["src/**/*.spec.ts", "src/test-setup.ts", "jest.config.ts", "src/**/*.test.ts"],
+ "include": ["src/**/*.ts"]
+}
diff --git a/libs/ui/tooltip/brain/tsconfig.lib.prod.json b/libs/ui/tooltip/brain/tsconfig.lib.prod.json
new file mode 100644
index 000000000..7b29b93f6
--- /dev/null
+++ b/libs/ui/tooltip/brain/tsconfig.lib.prod.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.lib.json",
+ "compilerOptions": {
+ "declarationMap": false
+ },
+ "angularCompilerOptions": {
+ "compilationMode": "partial"
+ }
+}
diff --git a/libs/ui/tooltip/brain/tsconfig.spec.json b/libs/ui/tooltip/brain/tsconfig.spec.json
new file mode 100644
index 000000000..1b52ac828
--- /dev/null
+++ b/libs/ui/tooltip/brain/tsconfig.spec.json
@@ -0,0 +1,11 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../../../dist/out-tsc",
+ "module": "commonjs",
+ "target": "es2016",
+ "types": ["jest", "node", "jsdom"]
+ },
+ "files": ["src/test-setup.ts"],
+ "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
+}
diff --git a/libs/ui/tooltip/helm/.eslintrc.json b/libs/ui/tooltip/helm/.eslintrc.json
new file mode 100644
index 000000000..84eabd35c
--- /dev/null
+++ b/libs/ui/tooltip/helm/.eslintrc.json
@@ -0,0 +1,34 @@
+{
+ "extends": ["../../../../.eslintrc.json"],
+ "ignorePatterns": ["!**/*"],
+ "overrides": [
+ {
+ "files": ["*.ts"],
+ "rules": {
+ "@angular-eslint/no-host-metadata-property": 0,
+ "@angular-eslint/directive-selector": [
+ "error",
+ {
+ "type": "attribute",
+ "prefix": "hlm",
+ "style": "camelCase"
+ }
+ ],
+ "@angular-eslint/component-selector": [
+ "error",
+ {
+ "type": "element",
+ "prefix": "hlm",
+ "style": "kebab-case"
+ }
+ ]
+ },
+ "extends": ["plugin:@nx/angular", "plugin:@angular-eslint/template/process-inline-templates"]
+ },
+ {
+ "files": ["*.html"],
+ "extends": ["plugin:@nx/angular-template"],
+ "rules": {}
+ }
+ ]
+}
diff --git a/libs/ui/tooltip/helm/README.md b/libs/ui/tooltip/helm/README.md
new file mode 100644
index 000000000..c9d4d7ac8
--- /dev/null
+++ b/libs/ui/tooltip/helm/README.md
@@ -0,0 +1,7 @@
+# ui-tooltip-helm
+
+This library was generated with [Nx](https://nx.dev).
+
+## Running unit tests
+
+Run `nx test ui-tooltip-helm` to execute the unit tests.
diff --git a/libs/ui/tooltip/helm/jest.config.ts b/libs/ui/tooltip/helm/jest.config.ts
new file mode 100644
index 000000000..223b828a4
--- /dev/null
+++ b/libs/ui/tooltip/helm/jest.config.ts
@@ -0,0 +1,21 @@
+/* eslint-disable */
+export default {
+ displayName: 'ui-tooltip-helm',
+ preset: '../../../../jest.preset.js',
+ setupFilesAfterEnv: ['/src/test-setup.ts'],
+ transform: {
+ '^.+\\.(ts|mjs|js|html)$': [
+ 'jest-preset-angular',
+ {
+ tsconfig: '/tsconfig.spec.json',
+ stringifyContentPathRegex: '\\.(html|svg)$',
+ },
+ ],
+ },
+ transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
+ snapshotSerializers: [
+ 'jest-preset-angular/build/serializers/no-ng-attributes',
+ 'jest-preset-angular/build/serializers/ng-snapshot',
+ 'jest-preset-angular/build/serializers/html-comment',
+ ],
+};
diff --git a/libs/ui/tooltip/helm/ng-package.json b/libs/ui/tooltip/helm/ng-package.json
new file mode 100644
index 000000000..3b0a9f312
--- /dev/null
+++ b/libs/ui/tooltip/helm/ng-package.json
@@ -0,0 +1,7 @@
+{
+ "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json",
+ "dest": "../../../../dist/libs/ui/tooltip/helm",
+ "lib": {
+ "entryFile": "src/index.ts"
+ }
+}
diff --git a/libs/ui/tooltip/helm/package.json b/libs/ui/tooltip/helm/package.json
new file mode 100644
index 000000000..bc0a01637
--- /dev/null
+++ b/libs/ui/tooltip/helm/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@spartan-ng/ui-tooltip-helm",
+ "version": "0.0.1-alpha.10",
+ "peerDependencies": {
+ "@angular/core": "17.0.2"
+ },
+ "dependencies": {},
+ "sideEffects": false,
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/libs/ui/tooltip/helm/project.json b/libs/ui/tooltip/helm/project.json
new file mode 100644
index 000000000..b05950349
--- /dev/null
+++ b/libs/ui/tooltip/helm/project.json
@@ -0,0 +1,58 @@
+{
+ "name": "ui-tooltip-helm",
+ "$schema": "../../../../node_modules/nx/schemas/project-schema.json",
+ "sourceRoot": "libs/ui/tooltip/helm/src",
+ "prefix": "helm",
+ "tags": ["scope:helm"],
+ "projectType": "library",
+ "targets": {
+ "build": {
+ "executor": "@nx/angular:package",
+ "outputs": ["{workspaceRoot}/dist/{projectRoot}"],
+ "options": {
+ "project": "libs/ui/tooltip/helm/ng-package.json"
+ },
+ "configurations": {
+ "production": {
+ "tsConfig": "libs/ui/tooltip/helm/tsconfig.lib.prod.json"
+ },
+ "development": {
+ "tsConfig": "libs/ui/tooltip/helm/tsconfig.lib.json"
+ }
+ },
+ "defaultConfiguration": "production"
+ },
+ "test": {
+ "executor": "@nx/jest:jest",
+ "outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
+ "options": {
+ "jestConfig": "libs/ui/tooltip/helm/jest.config.ts",
+ "passWithNoTests": true
+ },
+ "configurations": {
+ "ci": {
+ "ci": true,
+ "codeCoverage": true
+ }
+ }
+ },
+ "lint": {
+ "executor": "@nx/linter:eslint",
+ "outputs": ["{options.outputFile}"],
+ "options": {
+ "lintFilePatterns": [
+ "libs/ui/tooltip/helm/**/*.ts",
+ "libs/ui/tooltip/helm/**/*.html",
+ "libs/ui/tooltip/helm/package.json",
+ "libs/ui/tooltip/helm/project.json"
+ ]
+ }
+ },
+ "release": {
+ "executor": "@spartan-ng/tools:build-update-publish",
+ "options": {
+ "libName": "ui-tooltip-helm"
+ }
+ }
+ }
+}
diff --git a/libs/ui/tooltip/helm/src/index.ts b/libs/ui/tooltip/helm/src/index.ts
new file mode 100644
index 000000000..d3db812f5
--- /dev/null
+++ b/libs/ui/tooltip/helm/src/index.ts
@@ -0,0 +1,12 @@
+import { NgModule } from '@angular/core';
+import { HlmTooltipDirective } from './lib/hlm-tooltip.directive';
+
+export * from './lib/hlm-tooltip.directive';
+
+export const HlmTooltipImports = [HlmTooltipDirective] as const;
+
+@NgModule({
+ imports: [...HlmTooltipImports],
+ exports: [...HlmTooltipImports],
+})
+export class HlmTooltipModule {}
diff --git a/libs/ui/tooltip/helm/src/lib/hlm-tooltip.directive.ts b/libs/ui/tooltip/helm/src/lib/hlm-tooltip.directive.ts
new file mode 100644
index 000000000..5c2710464
--- /dev/null
+++ b/libs/ui/tooltip/helm/src/lib/hlm-tooltip.directive.ts
@@ -0,0 +1,41 @@
+import { Directive, inject } from '@angular/core';
+import { BrnTooltipDirective } from '@spartan-ng/ui-tooltip-brain';
+
+@Directive({
+ selector: '[hlmTooltip]',
+ standalone: true,
+ hostDirectives: [
+ {
+ directive: BrnTooltipDirective,
+ inputs: [
+ 'brnTooltip: hlmTooltip',
+ 'aria-describedby',
+ 'disabled',
+ 'position',
+ 'positionAtOrigin',
+ 'hideDelay',
+ 'showDelay',
+ 'exitAnimationDuration',
+ 'touchGestures',
+ ],
+ },
+ ],
+})
+export class HlmTooltipDirective {
+ private readonly _brnTooltip: BrnTooltipDirective = inject(BrnTooltipDirective, { host: true });
+
+ constructor() {
+ if (this._brnTooltip) {
+ this._brnTooltip.exitAnimationDuration = 150;
+ this._brnTooltip.hideDelay = 300;
+ this._brnTooltip.showDelay = 150;
+ this._brnTooltip.tooltipContentClasses =
+ 'overflow-hidden rounded-md border border-border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md fade-in-0 zoom-in-95 ' +
+ 'data-[state=open]:animate-in ' +
+ 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 ' +
+ 'data-[side=below]:slide-in-from-top-2 data-[side=above]:slide-in-from-bottom-2 ' +
+ 'data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 ' +
+ 'data-[side=after]:slide-in-from-left-2 data-[side=before]:slide-in-from-right-2 ';
+ }
+ }
+}
diff --git a/libs/ui/tooltip/helm/src/test-setup.ts b/libs/ui/tooltip/helm/src/test-setup.ts
new file mode 100644
index 000000000..b2dd6e939
--- /dev/null
+++ b/libs/ui/tooltip/helm/src/test-setup.ts
@@ -0,0 +1,8 @@
+// @ts-expect-error https://thymikee.github.io/jest-preset-angular/docs/getting-started/test-environment
+globalThis.ngJest = {
+ testEnvironmentOptions: {
+ errorOnUnknownElements: true,
+ errorOnUnknownProperties: true,
+ },
+};
+import 'jest-preset-angular/setup-jest';
diff --git a/libs/ui/tooltip/helm/tsconfig.json b/libs/ui/tooltip/helm/tsconfig.json
new file mode 100644
index 000000000..652fa49ce
--- /dev/null
+++ b/libs/ui/tooltip/helm/tsconfig.json
@@ -0,0 +1,29 @@
+{
+ "compilerOptions": {
+ "target": "es2022",
+ "useDefineForClassFields": false,
+ "forceConsistentCasingInFileNames": true,
+ "strict": true,
+ "noImplicitOverride": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "files": [],
+ "include": [],
+ "references": [
+ {
+ "path": "./tsconfig.lib.json"
+ },
+ {
+ "path": "./tsconfig.spec.json"
+ }
+ ],
+ "extends": "../../../../tsconfig.base.json",
+ "angularCompilerOptions": {
+ "enableI18nLegacyMessageIdFormat": false,
+ "strictInjectionParameters": true,
+ "strictInputAccessModifiers": true,
+ "strictTemplates": true
+ }
+}
diff --git a/libs/ui/tooltip/helm/tsconfig.lib.json b/libs/ui/tooltip/helm/tsconfig.lib.json
new file mode 100644
index 000000000..e82bb223c
--- /dev/null
+++ b/libs/ui/tooltip/helm/tsconfig.lib.json
@@ -0,0 +1,12 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../../../dist/out-tsc",
+ "declaration": true,
+ "declarationMap": true,
+ "inlineSources": true,
+ "types": []
+ },
+ "exclude": ["src/**/*.spec.ts", "src/test-setup.ts", "jest.config.ts", "src/**/*.test.ts"],
+ "include": ["src/**/*.ts"]
+}
diff --git a/libs/ui/tooltip/helm/tsconfig.lib.prod.json b/libs/ui/tooltip/helm/tsconfig.lib.prod.json
new file mode 100644
index 000000000..7b29b93f6
--- /dev/null
+++ b/libs/ui/tooltip/helm/tsconfig.lib.prod.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.lib.json",
+ "compilerOptions": {
+ "declarationMap": false
+ },
+ "angularCompilerOptions": {
+ "compilationMode": "partial"
+ }
+}
diff --git a/libs/ui/tooltip/helm/tsconfig.spec.json b/libs/ui/tooltip/helm/tsconfig.spec.json
new file mode 100644
index 000000000..40aad461f
--- /dev/null
+++ b/libs/ui/tooltip/helm/tsconfig.spec.json
@@ -0,0 +1,11 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../../../dist/out-tsc",
+ "module": "commonjs",
+ "target": "es2016",
+ "types": ["jest", "node"]
+ },
+ "files": ["src/test-setup.ts"],
+ "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
+}
diff --git a/libs/ui/tooltip/tooltip.stories.ts b/libs/ui/tooltip/tooltip.stories.ts
new file mode 100644
index 000000000..e25ab5197
--- /dev/null
+++ b/libs/ui/tooltip/tooltip.stories.ts
@@ -0,0 +1,43 @@
+import { provideIcons } from '@ng-icons/core';
+import { radixPlus } from '@ng-icons/radix-icons';
+import { Meta, moduleMetadata, StoryObj } from '@storybook/angular';
+import { HlmButtonDirective } from '../button/helm/src';
+import { HlmIconComponent } from '../icon/helm/src';
+import { TooltipPosition } from './brain/src';
+import { HlmTooltipDirective } from './helm/src';
+
+const meta: Meta<{}> = {
+ argTypes: {},
+ title: 'Tooltip',
+ decorators: [
+ moduleMetadata({
+ imports: [HlmButtonDirective, HlmTooltipDirective, HlmIconComponent],
+ providers: [provideIcons({ radixPlus })],
+ }),
+ ],
+};
+
+export default meta;
+type Story = StoryObj<{ position: TooltipPosition }>;
+export const Default: Story = {
+ argTypes: {
+ position: {
+ control: { type: 'radio' },
+ options: ['above', 'below', 'left', 'right'],
+ },
+ },
+ render: (args) => ({
+ props: { position: args.position },
+ template: `
+
+
+
+
+
+
+ Add to library
+
+
+`,
+ }),
+};
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 0db5b94fc..df13b8464 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -64,6 +64,8 @@
"@spartan-ng/ui-table-helm": ["libs/ui/table/helm/src/index.ts"],
"@spartan-ng/ui-tabs-brain": ["libs/ui/tabs/brain/src/index.ts"],
"@spartan-ng/ui-tabs-helm": ["libs/ui/tabs/helm/src/index.ts"],
+ "@spartan-ng/ui-tooltip-brain": ["libs/ui/tooltip/brain/src/index.ts"],
+ "@spartan-ng/ui-tooltip-helm": ["libs/ui/tooltip/helm/src/index.ts"],
"@spartan-ng/ui-toggle-brain": ["libs/ui/toggle/brain/src/index.ts"],
"@spartan-ng/ui-toggle-helm": ["libs/ui/toggle/helm/src/index.ts"],
"@spartan-ng/ui-typography-helm": ["libs/ui/typography/helm/src/index.ts"],