diff --git a/alfa-client/apps/demo/src/app/app.component.html b/alfa-client/apps/demo/src/app/app.component.html index bae7d97a832d82ad6e91c77233ff11ea7eb6bc88..25e02f33b59d8a32db4332a88a6fef1d2e675c76 100644 --- a/alfa-client/apps/demo/src/app/app.component.html +++ b/alfa-client/apps/demo/src/app/app.component.html @@ -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> diff --git a/alfa-client/apps/demo/src/app/app.component.ts b/alfa-client/apps/demo/src/app/app.component.ts index d2ac3257386258a3bd111f893c0afc1616c666cc..eef4a756761f2ad91604ffd019aa645a2c97b9f6 100644 --- a/alfa-client/apps/demo/src/app/app.component.ts +++ b/alfa-client/apps/demo/src/app/app.component.ts @@ -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', diff --git a/alfa-client/libs/design-system/src/index.ts b/alfa-client/libs/design-system/src/index.ts index d64022f37b2b81e968b4bed34e2eed7d5741ccae..055062c48ce72b9d037dea89020b24bd0b706fae 100644 --- a/alfa-client/libs/design-system/src/index.ts +++ b/alfa-client/libs/design-system/src/index.ts @@ -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'; diff --git a/alfa-client/libs/design-system/src/lib/tooltip/tooltip.component.spec.ts b/alfa-client/libs/design-system/src/lib/tooltip/tooltip.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..fc39cb7ceafa17c9865a7390f9ab98e2298bf127 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/tooltip/tooltip.component.spec.ts @@ -0,0 +1,21 @@ +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(); + }); +}); diff --git a/alfa-client/libs/design-system/src/lib/tooltip/tooltip.component.ts b/alfa-client/libs/design-system/src/lib/tooltip/tooltip.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..a3ae962af474a3cf0a5cd2aa6744996e65383611 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/tooltip/tooltip.component.ts @@ -0,0 +1,22 @@ +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; +} diff --git a/alfa-client/libs/design-system/src/lib/tooltip/tooltip.directive.spec.ts b/alfa-client/libs/design-system/src/lib/tooltip/tooltip.directive.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..07895edc7c9aa5622948043a663ea3c940e0375b --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/tooltip/tooltip.directive.spec.ts @@ -0,0 +1,221 @@ +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(); + }); + }); +}); diff --git a/alfa-client/libs/design-system/src/lib/tooltip/tooltip.directive.ts b/alfa-client/libs/design-system/src/lib/tooltip/tooltip.directive.ts new file mode 100644 index 0000000000000000000000000000000000000000..6af9df79e87cf64bdb3c73867a9a631e1f2b210a --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/tooltip/tooltip.directive.ts @@ -0,0 +1,98 @@ +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; + } + } +} diff --git a/alfa-client/libs/design-system/src/lib/tooltip/tooltip.stories.ts b/alfa-client/libs/design-system/src/lib/tooltip/tooltip.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..f380cd055b3215670369b0d30fbbe2e00ef18921 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/tooltip/tooltip.stories.ts @@ -0,0 +1,29 @@ +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>', + }), +}; diff --git a/alfa-client/tsconfig.base.json b/alfa-client/tsconfig.base.json index 5fa256e28ccf0dd8ac93135af48e7e0180ed359d..63b6a42cd7681cb3f77bb8a38a1eea56cc5a527b 100644 --- a/alfa-client/tsconfig.base.json +++ b/alfa-client/tsconfig.base.json @@ -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"]