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 index dd996ceb554faca8a93db44f758dbe057163e438..a3ae962af474a3cf0a5cd2aa6744996e65383611 100644 --- a/alfa-client/libs/design-system/src/lib/tooltip/tooltip.component.ts +++ b/alfa-client/libs/design-system/src/lib/tooltip/tooltip.component.ts @@ -3,14 +3,16 @@ import { Component } from '@angular/core'; @Component({ selector: 'ods-tooltip', template: `<p - class="fixed z-50 mt-2 -translate-x-1/2 animate-fadeIn 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" + 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 = ''; 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 index 7047e82146d33a4c82584f4c85f531fcf2a85fbd..07895edc7c9aa5622948043a663ea3c940e0375b 100644 --- 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 @@ -1,34 +1,221 @@ -import { ElementRef, ViewContainerRef } from '@angular/core'; +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'; -export class MockElementRef extends ElementRef {} +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 }], + providers: [ViewContainerRef, { provide: ElementRef, useClass: MockElementRef }, Renderer2, InteractivityChecker], + }); + TestBed.runInInjectionContext(() => { + directive = new TooltipDirective(); }); }); - it('should create an instance', () => { - TestBed.runInInjectionContext(() => { - const directive: TooltipDirective = new TooltipDirective(); + it('should create a directive', () => { + expect(directive).toBeTruthy(); + }); - expect(directive).toBeTruthy(); + describe('ngOnDestroy', () => { + it('should destroy tooltip', () => { + directive.destroy = jest.fn(); + + directive.ngOnDestroy(); + + expect(directive.destroy).toHaveBeenCalled(); }); }); - describe('ngOnDestroy', () => { + 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', () => { - TestBed.runInInjectionContext(() => { - const directive = new TooltipDirective(); - directive.destroy = jest.fn(); + 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); - directive.ngOnDestroy(); + 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.destroy).toHaveBeenCalled(); + 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 index 69a4cfe48bc99cf92061ab0eb9d8f361da980029..6af9df79e87cf64bdb3c73867a9a631e1f2b210a 100644 --- a/alfa-client/libs/design-system/src/lib/tooltip/tooltip.directive.ts +++ b/alfa-client/libs/design-system/src/lib/tooltip/tooltip.directive.ts @@ -1,12 +1,14 @@ +import { isEscapeKey } from '@alfa-client/tech-shared'; +import { InteractivityChecker } from '@angular/cdk/a11y'; import { ComponentRef, Directive, ElementRef, - HostBinding, HostListener, inject, Input, OnDestroy, + Renderer2, ViewContainerRef, } from '@angular/core'; import { uniqueId } from 'lodash-es'; @@ -19,27 +21,28 @@ import { TooltipComponent } from './tooltip.component'; export class TooltipDirective implements OnDestroy { @Input() tooltip: string = ''; - private componentRef: ComponentRef<TooltipComponent> = null; - private tooltipId: string; - public readonly viewContainerRef: ViewContainerRef = inject(ViewContainerRef); - public readonly elementRef: ElementRef<HTMLElement> = inject(ElementRef); + 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(); } - @HostBinding('attr.aria-describedby') get describedBy() { - return this.tooltipId; - } - @HostListener('mouseenter') @HostListener('focusin') createTooltip(): void { if (this.componentRef === null) { - const attachedToFocused: boolean = this.elementRef.nativeElement.contains(document.activeElement); - this.tooltipId = uniqueId('tooltip'); + const nativeElement: HTMLElement = this.elementRef.nativeElement; + const attachedToFocused: boolean = nativeElement.contains(document.activeElement); this.componentRef = this.viewContainerRef.createComponent(TooltipComponent); - this.viewContainerRef.insert(this.componentRef.hostView); + nativeElement.appendChild(this.componentRef.location.nativeElement); + this.setDescribedBy(); this.setTooltipProperties(attachedToFocused); } } @@ -51,8 +54,9 @@ export class TooltipDirective implements OnDestroy { this.destroy(); } - @HostListener('keydown', ['$event']) onKeydown(e: KeyboardEvent) { - if (this.isEscapeKey(e)) { + @HostListener('keydown', ['$event']) + onKeydown(e: KeyboardEvent): void { + if (isEscapeKey(e)) { this.destroy(); } } @@ -67,15 +71,28 @@ export class TooltipDirective implements OnDestroy { } } + 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.tooltipId = null; + this.removeDescribedBy(); + this.focusableElement = null; } } - - isEscapeKey(e: KeyboardEvent): boolean { - return e.key === 'Escape'; - } }