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"],