Skip to content
Snippets Groups Projects
Commit 1dcd6114 authored by OZGCloud's avatar OZGCloud
Browse files

Merge pull request 'OZG-7047-tooltip' (#838) from OZG-7047-tooltip into master

parents f7733506 3085c271
No related branches found
No related tags found
No related merge requests found
......@@ -19,6 +19,7 @@
<a
href="#"
class="flex flex-col items-start justify-between gap-2 rounded-t-md border-primary-600/50 px-6 py-4 hover:bg-background-150 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus lg:flex-row lg:gap-6"
tooltip="This is tooltip attached to link element"
>
<div class="flex-1 basis-5/6">
<div class="flex flex-wrap items-center gap-x-3">
......@@ -396,7 +397,7 @@
</form>
<app-bescheid-dialog-button></app-bescheid-dialog-button>
<div class="my-4 flex gap-4">
<ods-button text="Button 1" />
<ods-button text="Button 1" tooltip="Sample tooltip" />
<ods-button size="medium" [isLoading]="true" text="Button 2" />
<ods-button type="outline" text="Button 3" />
</div>
......
......@@ -15,10 +15,8 @@ import {
CloseIconComponent,
ErrorMessageComponent,
FieldsetComponent,
FileIconComponent,
FileUploadButtonComponent,
InstantSearchComponent,
OfficeIconComponent,
RadioButtonCardComponent,
SaveIconComponent,
SendIconComponent,
......@@ -26,6 +24,7 @@ import {
StampIconComponent,
TextInputComponent,
TextareaComponent,
TooltipDirective,
} from '@ods/system';
import { EMPTY_STRING } from '@alfa-client/tech-shared';
......@@ -35,8 +34,6 @@ import {
InstantSearchResult,
} from 'libs/design-system/src/lib/instant-search/instant-search/instant-search.model';
import { BescheidDialogExampleComponent } from './components/bescheid-dialog/bescheid-dialog.component';
import { BescheidPaperComponent } from './components/bescheid-paper/bescheid-paper.component';
import { BescheidStepperComponent } from './components/bescheid-stepper/bescheid-stepper.component';
import { CustomStepperComponent } from './components/cdk-demo/custom-stepper.component';
@Component({
......@@ -54,12 +51,9 @@ import { CustomStepperComponent } from './components/cdk-demo/custom-stepper.com
CdkStepperModule,
CustomStepperComponent,
BescheidDialogExampleComponent,
BescheidStepperComponent,
BescheidPaperComponent,
RadioButtonCardComponent,
ReactiveFormsModule,
InstantSearchComponent,
OfficeIconComponent,
SaveIconComponent,
SendIconComponent,
StampIconComponent,
......@@ -68,11 +62,11 @@ import { CustomStepperComponent } from './components/cdk-demo/custom-stepper.com
BescheidGenerateIconComponent,
BescheidUploadIconComponent,
SpinnerIconComponent,
FileIconComponent,
TextareaComponent,
TextInputComponent,
TextareaComponent,
ErrorMessageComponent,
TooltipDirective,
],
selector: 'app-root',
templateUrl: './app.component.html',
......
......@@ -47,3 +47,4 @@ export * from './lib/list/list.component';
export * from './lib/navbar/nav-item/nav-item.component';
export * from './lib/navbar/navbar/navbar.component';
export * from './lib/testbtn/testbtn.component';
export * from './lib/tooltip/tooltip.directive';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TooltipComponent } from './tooltip.component';
describe('TooltipComponent', () => {
let component: TooltipComponent;
let fixture: ComponentFixture<TooltipComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TooltipComponent],
}).compileComponents();
fixture = TestBed.createComponent(TooltipComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
import { Component } from '@angular/core';
@Component({
selector: 'ods-tooltip',
template: `<p
class="fixed z-50 mt-2 -translate-x-1/2 animate-fadeIn cursor-default rounded bg-ozggray-900 px-3 py-2 text-sm text-whitetext before:absolute before:-top-2 before:left-[calc(50%-0.5rem)] before:size-0 before:border-b-8 before:border-l-8 before:border-r-8 before:border-b-ozggray-900 before:border-l-transparent before:border-r-transparent before:content-[''] dark:bg-white dark:before:border-b-white"
[style.left]="left + 'px'"
[style.top]="top + 'px'"
[attr.id]="id"
role="tooltip"
>
{{ text }}
</p>`,
styles: [':host {@apply contents}'],
standalone: true,
})
export class TooltipComponent {
text: string = '';
left: number = 0;
top: number = 0;
id: string;
}
import { InteractivityChecker } from '@angular/cdk/a11y';
import { ComponentRef, ElementRef, Renderer2, ViewContainerRef } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { TooltipComponent } from './tooltip.component';
import { TooltipDirective } from './tooltip.directive';
class MockElementRef extends ElementRef {
nativeElement = {
contains: jest.fn(),
appendChild: jest.fn(),
};
}
describe('TooltipDirective', () => {
let directive: TooltipDirective;
const mockComponentRef: ComponentRef<TooltipComponent> = {
setInput: jest.fn(),
destroy: jest.fn(),
onDestroy: jest.fn(),
componentType: TooltipComponent,
changeDetectorRef: null,
location: null,
hostView: null,
injector: null,
instance: { id: '', left: 0, top: 0, text: '' },
};
beforeEach((): void => {
TestBed.configureTestingModule({
providers: [ViewContainerRef, { provide: ElementRef, useClass: MockElementRef }, Renderer2, InteractivityChecker],
});
TestBed.runInInjectionContext(() => {
directive = new TooltipDirective();
});
});
it('should create a directive', () => {
expect(directive).toBeTruthy();
});
describe('ngOnDestroy', () => {
it('should destroy tooltip', () => {
directive.destroy = jest.fn();
directive.ngOnDestroy();
expect(directive.destroy).toHaveBeenCalled();
});
});
describe('createTooltip', () => {
beforeEach(() => {
directive.viewContainerRef.createComponent = jest.fn().mockReturnValue({ location: { nativeElement: {} } });
directive.setDescribedBy = jest.fn();
directive.setTooltipProperties = jest.fn();
});
it('should create tooltip component', () => {
directive.createTooltip();
expect(directive.viewContainerRef.createComponent).toHaveBeenCalled();
});
it('should insert tooltip component to parent', () => {
directive.createTooltip();
expect(directive.elementRef.nativeElement.appendChild).toHaveBeenCalled();
});
it('should set aria described by attribute to parent', () => {
directive.createTooltip();
expect(directive.setDescribedBy).toHaveBeenCalled();
});
it('should set tooltip properties', () => {
directive.createTooltip();
expect(directive.setTooltipProperties).toHaveBeenCalled();
});
});
describe('destroyTooltip', () => {
it('should destroy tooltip', () => {
directive.destroy = jest.fn();
directive.destroyTooltip();
expect(directive.destroy).toHaveBeenCalled();
});
});
describe('onKeydown', () => {
it('should destroy tooltip if escape key pressed', () => {
directive.destroy = jest.fn();
const escapeEvent: KeyboardEvent = { ...new KeyboardEvent('esc'), key: 'Escape' };
directive.onKeydown(escapeEvent);
expect(directive.destroy).toHaveBeenCalled();
});
});
describe('setTooltipProperties', () => {
beforeEach(() => {
directive.componentRef = mockComponentRef;
directive.elementRef.nativeElement.getBoundingClientRect = jest
.fn()
.mockReturnValue({ left: 0, right: 1000, bottom: 1000 });
});
it('should get bounding client rect', () => {
directive.setTooltipProperties();
expect(directive.elementRef.nativeElement.getBoundingClientRect).toHaveBeenCalled();
});
it('should set tooltip instance properties', () => {
directive.tooltip = 'I am tooltip';
directive.tooltipId = 'tooltip-1';
directive.setTooltipProperties();
expect(directive.componentRef.instance).toStrictEqual({
id: 'tooltip-1',
left: 500,
text: 'I am tooltip',
top: 1000,
});
});
it('should add margin if parent element focused', () => {
directive.setTooltipProperties(true);
expect(directive.componentRef.instance.top).toBe(1004);
});
});
describe('setDescribedBy', () => {
beforeEach(() => {
directive.getFocusableElement = jest.fn();
directive.renderer.setAttribute = jest.fn();
directive.interactivityChecker.isFocusable = jest.fn();
});
it('should check if parent element focusable', () => {
directive.setDescribedBy();
expect(directive.interactivityChecker.isFocusable).toHaveBeenCalled();
});
it('should get focusable element if parent not focusable', () => {
directive.setDescribedBy();
expect(directive.getFocusableElement).toHaveBeenCalled();
});
it('should set describedby attribute', () => {
directive.setDescribedBy();
expect(directive.renderer.setAttribute).toHaveBeenCalled();
});
});
describe('removeDescribedBy', () => {
beforeEach(() => {
directive.renderer.removeAttribute = jest.fn();
});
it('should remove describedby attribute', () => {
directive.removeDescribedBy();
expect(directive.renderer.removeAttribute).toHaveBeenCalled();
});
});
describe('getFocusableElement', () => {
it('should return null', () => {
const simpleElement = document.createElement('a');
const result: HTMLElement = directive.getFocusableElement(simpleElement);
expect(result).toBeNull();
});
it('should return focusable child element', () => {
const nestedElement = document.createElement('div');
const buttonElement = document.createElement('button');
nestedElement.appendChild(buttonElement);
const result: HTMLElement = directive.getFocusableElement(nestedElement);
expect(result).toBe(buttonElement);
});
});
describe('destroy', () => {
beforeEach(() => {
directive.componentRef = mockComponentRef;
directive.removeDescribedBy = jest.fn();
});
it('should set component ref to null', () => {
directive.destroy();
expect(directive.componentRef).toBeNull();
});
it('should remove describedby attribute', () => {
directive.destroy();
expect(directive.removeDescribedBy).toHaveBeenCalled();
});
it('should set focusable element to null', () => {
directive.destroy();
expect(directive.focusableElement).toBeNull();
});
});
});
import { isEscapeKey } from '@alfa-client/tech-shared';
import { InteractivityChecker } from '@angular/cdk/a11y';
import {
ComponentRef,
Directive,
ElementRef,
HostListener,
inject,
Input,
OnDestroy,
Renderer2,
ViewContainerRef,
} from '@angular/core';
import { uniqueId } from 'lodash-es';
import { TooltipComponent } from './tooltip.component';
@Directive({
selector: '[tooltip]',
standalone: true,
})
export class TooltipDirective implements OnDestroy {
@Input() tooltip: string = '';
componentRef: ComponentRef<TooltipComponent> = null;
focusableElement: HTMLElement = null;
tooltipId: string;
public viewContainerRef: ViewContainerRef = inject(ViewContainerRef);
public elementRef: ElementRef<HTMLElement> = inject(ElementRef);
public renderer: Renderer2 = inject(Renderer2);
public interactivityChecker: InteractivityChecker = inject(InteractivityChecker);
ngOnDestroy(): void {
this.destroy();
}
@HostListener('mouseenter')
@HostListener('focusin')
createTooltip(): void {
if (this.componentRef === null) {
const nativeElement: HTMLElement = this.elementRef.nativeElement;
const attachedToFocused: boolean = nativeElement.contains(document.activeElement);
this.componentRef = this.viewContainerRef.createComponent(TooltipComponent);
nativeElement.appendChild(this.componentRef.location.nativeElement);
this.setDescribedBy();
this.setTooltipProperties(attachedToFocused);
}
}
@HostListener('mouseleave')
@HostListener('window:scroll')
@HostListener('focusout')
destroyTooltip(): void {
this.destroy();
}
@HostListener('keydown', ['$event'])
onKeydown(e: KeyboardEvent): void {
if (isEscapeKey(e)) {
this.destroy();
}
}
setTooltipProperties(attachedToFocused = false): void {
if (this.componentRef !== null) {
const { left, right, bottom } = this.elementRef.nativeElement.getBoundingClientRect();
this.componentRef.instance.left = (right + left) / 2;
this.componentRef.instance.top = attachedToFocused ? bottom + 4 : bottom;
this.componentRef.instance.text = this.tooltip;
this.componentRef.instance.id = this.tooltipId;
}
}
setDescribedBy(): void {
const nativeElement: HTMLElement = this.elementRef.nativeElement;
this.tooltipId = uniqueId('tooltip');
this.focusableElement =
this.interactivityChecker.isFocusable(nativeElement) ? nativeElement : this.getFocusableElement(nativeElement);
this.renderer.setAttribute(this.focusableElement, 'aria-describedby', this.tooltipId);
}
removeDescribedBy(): void {
this.renderer.removeAttribute(this.focusableElement, 'aria-describedby');
}
getFocusableElement(element: HTMLElement): HTMLElement {
return element.querySelector('a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])');
}
destroy(): void {
if (this.componentRef !== null) {
this.componentRef.destroy();
this.componentRef = null;
this.removeDescribedBy();
this.focusableElement = null;
}
}
}
import { moduleMetadata, type Meta, type StoryObj } from '@storybook/angular';
import { TooltipDirective } from './tooltip.directive';
const meta: Meta = {
title: 'Tooltip',
excludeStories: /.*Data$/,
tags: ['autodocs'],
decorators: [
moduleMetadata({
imports: [TooltipDirective],
}),
],
parameters: {
docs: {
description: {
component: 'Tooltip directive that can be used with every element (check out default story to see tooltip working).',
},
},
},
};
export default meta;
type Story = StoryObj;
export const Default: Story = {
render: () => ({
template: '<button tooltip="Hello">I have a tooltip!</button>',
}),
};
......@@ -26,6 +26,7 @@
"@alfa-client/collaboration": ["libs/collaboration/src/index.ts"],
"@alfa-client/collaboration-shared": ["libs/collaboration-shared/src/index.ts"],
"@alfa-client/command-shared": ["libs/command-shared/src/index.ts"],
"@alfa-client/common": ["libs/common/src/index.ts"],
"@alfa-client/environment-shared": ["libs/environment-shared/src/index.ts"],
"@alfa-client/forwarding": ["libs/forwarding/src/index.ts"],
"@alfa-client/forwarding-shared": ["libs/forwarding-shared/src/index.ts"],
......@@ -61,8 +62,7 @@
"@alfa-client/zustaendige-stelle-shared": ["libs/zustaendige-stelle-shared/src/index.ts"],
"@ods/component": ["libs/design-component/src/index.ts"],
"@ods/system": ["libs/design-system/src/index.ts"],
"authentication": ["libs/authentication/src/index.ts"],
"@alfa-client/common": ["libs/common/src/index.ts"]
"authentication": ["libs/authentication/src/index.ts"]
}
},
"exclude": ["node_modules", "tmp"]
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment