diff --git a/alfa-client/libs/design-system/src/index.ts b/alfa-client/libs/design-system/src/index.ts index f64d1a010fc9f07ee30ab2eda51004b23b1db7eb..b2a574838f9116b672a021322bfa1b5a009f147c 100644 --- a/alfa-client/libs/design-system/src/index.ts +++ b/alfa-client/libs/design-system/src/index.ts @@ -23,6 +23,6 @@ export * from './lib/icons/send-icon/send-icon.component'; export * from './lib/icons/spinner-icon/spinner-icon.component'; export * from './lib/icons/stamp-icon/stamp-icon.component'; export * from './lib/instant-search/instant-search/instant-search.component'; -export * from './lib/popup/popup-layer/popup-layer.component'; export * from './lib/popup/popup-list-item/popup-list-item.component'; +export * from './lib/popup/popup/popup.component'; export * from './lib/testbtn/testbtn.component'; diff --git a/alfa-client/libs/design-system/src/lib/popup/popup-layer/popup-layer.component.spec.ts b/alfa-client/libs/design-system/src/lib/popup/popup-layer/popup-layer.component.spec.ts deleted file mode 100644 index 2339d08c85d4982936b7376995b145aa8518310c..0000000000000000000000000000000000000000 --- a/alfa-client/libs/design-system/src/lib/popup/popup-layer/popup-layer.component.spec.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { getElementFromFixture } from '@alfa-client/test-utils'; -import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; -import { getDataTestIdOf } from 'libs/tech-shared/test/data-test'; -import { PopupLayerComponent } from './popup-layer.component'; - -describe('PopupLayerComponent', () => { - let component: PopupLayerComponent; - let fixture: ComponentFixture<PopupLayerComponent>; - const popupButton: string = getDataTestIdOf('popup-button'); - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PopupLayerComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(PopupLayerComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - describe('toggleShowPopupList', () => { - it('should change false to true', () => { - component.showPopupList = false; - - component.toggleShowPopupList(); - - expect(component.showPopupList).toBe(true); - }); - - it('should change true to false', () => { - component.showPopupList = true; - - component.toggleShowPopupList(); - - expect(component.showPopupList).toBe(false); - }); - }); - - describe('aria-expanded', () => { - it('should be true if popup is opened', () => { - component.showPopupList = true; - fixture.detectChanges(); - - const buttonElement: HTMLElement = getElementFromFixture(fixture, popupButton); - - expect(buttonElement.getAttribute('aria-expanded')).toBe('true'); - }); - - it('should be false if popup is closed', () => { - component.showPopupList = false; - fixture.detectChanges(); - - const buttonElement: HTMLElement = getElementFromFixture(fixture, popupButton); - - expect(buttonElement.getAttribute('aria-expanded')).toBe('false'); - }); - }); - - describe('toggleClicked', () => { - beforeEach(() => { - component.popupListRef = { - nativeElement: { - focus: jest.fn(), - }, - }; - }); - - it('should toggle popup list', () => { - component.toggleShowPopupList = jest.fn(); - - component.toggleClicked(); - - expect(component.toggleShowPopupList).toHaveBeenCalled(); - }); - - it('should focus popup list if it is shown', fakeAsync(() => { - component.toggleClicked(); - tick(); - - expect(component.popupListRef.nativeElement.focus).toHaveBeenCalled(); - })); - - it('should focus popup list if it is hidden', fakeAsync(() => { - component.showPopupList = true; - - component.toggleClicked(); - tick(); - - expect(component.popupListRef.nativeElement.focus).not.toHaveBeenCalled(); - })); - }); -}); diff --git a/alfa-client/libs/design-system/src/lib/popup/popup-layer/popup-layer.component.ts b/alfa-client/libs/design-system/src/lib/popup/popup-layer/popup-layer.component.ts deleted file mode 100644 index d17ca93f0bf6939b9a4d58e4b48d1f29794b7b4c..0000000000000000000000000000000000000000 --- a/alfa-client/libs/design-system/src/lib/popup/popup-layer/popup-layer.component.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, ElementRef, Input, ViewChild } from '@angular/core'; - -@Component({ - selector: 'ods-popup-layer', - standalone: true, - imports: [CommonModule], - template: `<div class="relative w-fit"> - <button - class="w-fit" - (click)="toggleClicked()" - [attr.aria-expanded]="showPopupList" - aria-haspopup="true" - [attr.aria-label]="label" - data-test-id="popup-button" - > - <ng-content select="[button]" /> - </button> - <ul - *ngIf="showPopupList" - class="animate-fadeIn absolute min-w-44 max-w-80 rounded shadow-lg shadow-grayborder focus:outline-none" - [ngClass]="alignTo === 'left' ? 'right-0' : 'left-0'" - role="dialog" - aria-modal="true" - tabindex="0" - #popupList - > - <ng-content /> - </ul> - </div>`, -}) -export class PopupLayerComponent { - @Input() alignTo: 'left' | 'right' = 'left'; - @Input() label: string = ''; - - @ViewChild('popupList') popupListRef: ElementRef; - - showPopupList: boolean = false; - - toggleShowPopupList(): void { - this.showPopupList = !this.showPopupList; - } - - toggleClicked(): void { - this.toggleShowPopupList(); - if (this.showPopupList) { - setTimeout(() => this.popupListRef.nativeElement.focus()); - } - } -} diff --git a/alfa-client/libs/design-system/src/lib/popup/popup-layer/popup-layer.stories.ts b/alfa-client/libs/design-system/src/lib/popup/popup-layer/popup-layer.stories.ts deleted file mode 100644 index 87df3b7f66189d82a83a34f9bc30957d06abeca0..0000000000000000000000000000000000000000 --- a/alfa-client/libs/design-system/src/lib/popup/popup-layer/popup-layer.stories.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { - argsToTemplate, - componentWrapperDecorator, - moduleMetadata, - type Meta, - type StoryObj, -} from '@storybook/angular'; - -import { PopupListItemComponent } from '../popup-list-item/popup-list-item.component'; -import { PopupLayerComponent } from './popup-layer.component'; - -const meta: Meta<PopupLayerComponent> = { - title: 'Popup/Popup layer', - component: PopupLayerComponent, - decorators: [ - moduleMetadata({ - imports: [PopupLayerComponent, PopupListItemComponent], - }), - componentWrapperDecorator((story) => `<div class="flex justify-center mb-20">${story}</div>`), - ], - excludeStories: /.*Data$/, - tags: ['autodocs'], -}; - -export default meta; -type Story = StoryObj<PopupLayerComponent>; - -export const Default: Story = { - args: { alignTo: 'left' }, - argTypes: { - alignTo: { - control: 'select', - options: ['left', 'right'], - table: { - defaultValue: { summary: 'left' }, - }, - }, - }, - render: (args) => ({ - props: args, - template: `<ods-popup-layer ${argsToTemplate(args)}> - <p button>Trigger popup</p> - <ods-popup-list-item caption="Lorem" /> - <ods-popup-list-item caption="Ipsum" /> - </ods-popup-layer>`, - }), -}; - -export const LongText: Story = { - render: (args) => ({ - props: args, - template: `<ods-popup-layer ${argsToTemplate(args)}> - <p button>Trigger popup</p> - <ods-popup-list-item caption="Lorem" /> - <ods-popup-list-item caption="Lorem ipsum dolor sit amet" /> - </ods-popup-layer>`, - }), -}; diff --git a/alfa-client/libs/design-system/src/lib/popup/popup-list-item/popup-list-item.component.spec.ts b/alfa-client/libs/design-system/src/lib/popup/popup-list-item/popup-list-item.component.spec.ts index 87b71e48a6416f8ea76cdcf0a1c9d5eafb4041bb..3d624f9674b26a1f03a2e024f0139f9eb8230746 100644 --- a/alfa-client/libs/design-system/src/lib/popup/popup-list-item/popup-list-item.component.spec.ts +++ b/alfa-client/libs/design-system/src/lib/popup/popup-list-item/popup-list-item.component.spec.ts @@ -21,7 +21,7 @@ describe('PopupListItemComponent', () => { }); describe('itemClicked emitter', () => { - it('should emit clickItem', () => { + it('should emit itemClicked', () => { component.itemClicked.emit = jest.fn(); dispatchEventFromFixture(fixture, 'button', 'click'); diff --git a/alfa-client/libs/design-system/src/lib/popup/popup/popup.component.spec.ts b/alfa-client/libs/design-system/src/lib/popup/popup/popup.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..f2a64dec5c9d60e36c292b55c6ae4a1b8d414f16 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/popup/popup/popup.component.spec.ts @@ -0,0 +1,587 @@ +import { getElementFromFixture } from '@alfa-client/test-utils'; +import { ElementRef } from '@angular/core'; +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { getDataTestIdOf } from 'libs/tech-shared/test/data-test'; +import { PopupComponent } from './popup.component'; + +describe('PopupComponent', () => { + let component: PopupComponent; + let fixture: ComponentFixture<PopupComponent>; + const popupButton: string = getDataTestIdOf('popup-button'); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PopupComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(PopupComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('toggleShowPopupList', () => { + it('should change false to true', () => { + component.showPopupList = false; + + component.toggleShowPopupList(); + + expect(component.showPopupList).toBe(true); + }); + + it('should change true to false', () => { + component.showPopupList = true; + + component.toggleShowPopupList(); + + expect(component.showPopupList).toBe(false); + }); + }); + + describe('aria-expanded', () => { + it('should be true if popup is opened', () => { + component.showPopupList = true; + fixture.detectChanges(); + + const buttonElement: HTMLElement = getElementFromFixture(fixture, popupButton); + + expect(buttonElement.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should be false if popup is closed', () => { + component.showPopupList = false; + fixture.detectChanges(); + + const buttonElement: HTMLElement = getElementFromFixture(fixture, popupButton); + + expect(buttonElement.getAttribute('aria-expanded')).toBe('false'); + }); + }); + + describe('toggleClicked', () => { + beforeEach(async () => { + component.showPopupList = true; + fixture.detectChanges(); + await fixture.whenStable(); + jest.spyOn(component.popupListRef.nativeElement, 'focus'); + }); + + it('should toggle popup list', () => { + component.toggleShowPopupList = jest.fn(); + + component.toggleClicked(); + + expect(component.toggleShowPopupList).toHaveBeenCalled(); + }); + + it('should focus popup list if it is shown', fakeAsync(() => { + component.showPopupList = false; + + component.toggleClicked(); + tick(); + + expect(component.popupListRef.nativeElement.focus).toHaveBeenCalled(); + })); + + it('should not focus popup list if it is hidden', fakeAsync(() => { + component.showPopupList = true; + + component.toggleClicked(); + tick(); + + expect(component.popupListRef.nativeElement.focus).not.toHaveBeenCalled(); + })); + }); + + describe('isTabKey', () => { + it('should return true', () => { + const tabKeyEvent: KeyboardEvent = { ...new KeyboardEvent('tab'), key: 'Tab' }; + + const result: boolean = component.isTabKey(tabKeyEvent); + + expect(result).toBe(true); + }); + + it('should return false', () => { + const keyEvent: KeyboardEvent = new KeyboardEvent('whatever'); + + const result: boolean = component.isTabKey(keyEvent); + + expect(result).toBe(false); + }); + }); + + describe('isShiftTabKey', () => { + it('should return true', () => { + const shiftTabKeyEvent: KeyboardEvent = { + ...new KeyboardEvent('tab'), + key: 'Tab', + shiftKey: true, + }; + + const result: boolean = component.isShiftTabKey(shiftTabKeyEvent); + + expect(result).toBe(true); + }); + + it('should return false', () => { + const tabKeyEvent: KeyboardEvent = { ...new KeyboardEvent('tab'), key: 'Tab' }; + + const result: boolean = component.isShiftTabKey(tabKeyEvent); + + expect(result).toBe(false); + }); + }); + + describe('isEscapeKey', () => { + it('should return true', () => { + const escapeKeyEvent: KeyboardEvent = { + ...new KeyboardEvent('esc'), + key: 'Escape', + }; + + const result: boolean = component.isEscapeKey(escapeKeyEvent); + + expect(result).toBe(true); + }); + + it('should return false', () => { + const keyEvent: KeyboardEvent = new KeyboardEvent('whatever'); + + const result: boolean = component.isEscapeKey(keyEvent); + + expect(result).toBe(false); + }); + }); + + describe('hidePopupListAndFocusButton', () => { + beforeEach(() => { + jest.spyOn(component.popupButtonRef.nativeElement, 'focus'); + }); + it('should hide popup list', () => { + component.showPopupList = true; + + component.hidePopupListAndFocusButton(); + + expect(component.showPopupList).toBe(false); + }); + + it('should focus button', () => { + component.showPopupList = true; + + component.hidePopupListAndFocusButton(); + + expect(component.popupButtonRef.nativeElement.focus).toHaveBeenCalled(); + }); + }); + + describe('isPopupListHidden', () => { + it('should return true', () => { + component.showPopupList = false; + + const result: boolean = component.isPopupListHidden(); + + expect(result).toBe(true); + }); + + it('should return false', () => { + component.showPopupList = true; + + const result: boolean = component.isPopupListHidden(); + + expect(result).toBe(false); + }); + }); + + describe('isFirstItemFocused', () => { + const document: Document = new Document(); + const firstElement: HTMLElement = document.createElement('a'); + const lastElement: HTMLElement = document.createElement('b'); + const element: HTMLElement = document.createElement('div'); + element.appendChild(firstElement); + element.appendChild(lastElement); + const popupList: ElementRef<HTMLElement> = { + nativeElement: element, + }; + + it('should return true', () => { + const documentWithFirstFocused = { ...document, activeElement: firstElement }; + + const result: boolean = component.isFirstItemFocused(popupList, documentWithFirstFocused); + + expect(result).toBe(true); + }); + + it('should return false if popup list is undefined', () => { + const documentWithNoFocused: Document = new Document(); + + const result: boolean = component.isFirstItemFocused(undefined, documentWithNoFocused); + + expect(result).toBe(false); + }); + + it('should return false if first element is not focused', () => { + const documentWithLastFocused = { ...document, activeElement: lastElement }; + + const result: boolean = component.isFirstItemFocused(popupList, documentWithLastFocused); + + expect(result).toBe(false); + }); + }); + + describe('isLastItemFocused', () => { + const document: Document = new Document(); + const firstElement: HTMLElement = document.createElement('a'); + const lastElement: HTMLElement = document.createElement('b'); + const element: HTMLElement = document.createElement('div'); + element.appendChild(firstElement); + element.appendChild(lastElement); + const popupList: ElementRef<HTMLElement> = { + nativeElement: element, + }; + + it('should return true', () => { + const documentWithLastFocused = { ...document, activeElement: lastElement }; + + const result: boolean = component.isLastItemFocused(popupList, documentWithLastFocused); + + expect(result).toBe(true); + }); + + it('should return false if popup list is undefined', () => { + const documentWithNoFocused: Document = new Document(); + + const result: boolean = component.isLastItemFocused(undefined, documentWithNoFocused); + + expect(result).toBe(false); + }); + + it('should return false if last element is not focused', () => { + const documentWithFirstFocused = { ...document, activeElement: firstElement }; + + const result: boolean = component.isLastItemFocused(popupList, documentWithFirstFocused); + + expect(result).toBe(false); + }); + }); + + describe('isPopupListFocused', () => { + let document: Document = new Document(); + const element: HTMLElement = document.createElement('div'); + const popupList: ElementRef<HTMLElement> = { + nativeElement: element, + }; + + it('should return true', () => { + const documentWithListFocused = { ...document, activeElement: element }; + + const result: boolean = component.isPopupListFocused(popupList, documentWithListFocused); + + expect(result).toBe(true); + }); + + it('should return false if popup list is undefined', () => { + const documentWithNoFocused: Document = new Document(); + + const result: boolean = component.isPopupListFocused(undefined, documentWithNoFocused); + + expect(result).toBe(false); + }); + + it('should return false if popup list is not focused', () => { + const documentWithNoFocused: Document = new Document(); + + const result: boolean = component.isPopupListFocused(popupList, documentWithNoFocused); + + expect(result).toBe(false); + }); + }); + + describe('focusFirstItem', () => { + const document: Document = new Document(); + const listElement: HTMLElement = document.createElement('ul'); + + const firstElement: HTMLElement = document.createElement('a'); + const lastElement: HTMLElement = document.createElement('b'); + const buttonElement: HTMLElement = document.createElement('button'); + buttonElement.focus = jest.fn(); + firstElement.appendChild(buttonElement); + + listElement.appendChild(firstElement); + listElement.appendChild(lastElement); + const popupList: ElementRef<HTMLElement> = { + nativeElement: listElement, + }; + + it('should get focusable element', () => { + component.getFocusableElement = jest.fn(); + + component.focusFirstItem(popupList); + + expect(component.getFocusableElement).toHaveBeenCalled(); + }); + + it('should focus first child', fakeAsync(() => { + component.focusFirstItem(popupList); + tick(); + + expect(buttonElement.focus).toHaveBeenCalled(); + })); + }); + + describe('focusLastItem', () => { + const document: Document = new Document(); + const listElement: HTMLElement = document.createElement('ul'); + + const firstElement: HTMLElement = document.createElement('a'); + const lastElement: HTMLElement = document.createElement('b'); + const buttonElement: HTMLElement = document.createElement('button'); + buttonElement.focus = jest.fn(); + lastElement.appendChild(buttonElement); + + listElement.appendChild(firstElement); + listElement.appendChild(lastElement); + const popupList: ElementRef<HTMLElement> = { + nativeElement: listElement, + }; + + it('should get focusable element', () => { + component.getFocusableElement = jest.fn(); + + component.focusLastItem(popupList); + + expect(component.getFocusableElement).toHaveBeenCalled(); + }); + + it('should focus first child', fakeAsync(() => { + component.focusLastItem(popupList); + tick(); + + expect(buttonElement.focus).toHaveBeenCalled(); + })); + }); + + describe('shouldFocusFirstItem', () => { + const keyboardEvent = new KeyboardEvent('e'); + + it('should return true if is tab key and last item is focused', () => { + component.isTabKey = jest.fn().mockReturnValue(true); + component.isLastItemFocused = jest.fn().mockReturnValue(true); + + const result: boolean = component.shouldFocusFirstItem(keyboardEvent); + + expect(result).toBe(true); + }); + + it('should return false', () => { + const result: boolean = component.shouldFocusFirstItem(keyboardEvent); + + expect(result).toBe(false); + }); + }); + + describe('shouldFocusLastItem', () => { + const keyboardEvent = new KeyboardEvent('e'); + + it('should return true if is shift tab key and first item is focused', () => { + component.isShiftTabKey = jest.fn().mockReturnValue(true); + component.isFirstItemFocused = jest.fn().mockReturnValue(true); + + const result: boolean = component.shouldFocusLastItem(keyboardEvent); + + expect(result).toBe(true); + }); + + it('should return true if is shift tab key and popup list is focused', () => { + component.isShiftTabKey = jest.fn().mockReturnValue(true); + component.isPopupListFocused = jest.fn().mockReturnValue(true); + + const result: boolean = component.shouldFocusLastItem(keyboardEvent); + + expect(result).toBe(true); + }); + + it('should return false', () => { + const result: boolean = component.shouldFocusLastItem(keyboardEvent); + + expect(result).toBe(false); + }); + }); + + describe('getFocusableElement', () => { + const document: Document = new Document(); + const element: HTMLElement = document.createElement('div'); + const buttonElement = document.createElement('button'); + element.appendChild(buttonElement); + + it('should return focusable element', () => { + const result: HTMLElement = component.getFocusableElement(element); + + expect(result).toBe(buttonElement); + }); + }); + + describe('onClickHandler', () => { + const e: MouseEvent = new MouseEvent('test'); + + beforeEach(() => { + component.hidePopupListAndFocusButton = jest.fn(); + }); + + it('should not handle click if popup is hidden', () => { + component.isPopupListHidden = jest.fn().mockReturnValue(true); + + component.onClickHandler(e); + + expect(component.hidePopupListAndFocusButton).not.toHaveBeenCalled(); + }); + + it('should hide popup and focus button if clicked outside of button', () => { + component.isPopupListHidden = jest.fn().mockReturnValue(false); + + component.onClickHandler(e); + + expect(component.hidePopupListAndFocusButton).toHaveBeenCalled(); + }); + + it('should not handle click if clicked inside of button', () => { + component.isPopupListHidden = jest.fn().mockReturnValue(false); + component.popupButtonRef.nativeElement.contains = jest.fn().mockReturnValue(true); + + component.onClickHandler(e); + + expect(component.hidePopupListAndFocusButton).not.toHaveBeenCalled(); + }); + }); + + describe('onKeydownHandler', () => { + const keyboardEvent: KeyboardEvent = new KeyboardEvent('e'); + + beforeEach(() => { + component.isPopupListHidden = jest.fn(); + component.isTabKey = jest.fn(); + component.isShiftTabKey = jest.fn(); + component.isEscapeKey = jest.fn(); + component.isFirstItemFocused = jest.fn(); + component.isLastItemFocused = jest.fn(); + component.focusFirstItem = jest.fn(); + component.focusLastItem = jest.fn(); + component.isPopupListFocused = jest.fn(); + component.shouldFocusFirstItem = jest.fn(); + component.shouldFocusLastItem = jest.fn(); + component.hidePopupListAndFocusButton = jest.fn(); + }); + + it('should check for hidden popup list', () => { + component.onKeydownHandler(keyboardEvent); + + expect(component.isPopupListHidden).toHaveBeenCalled(); + }); + + describe('popup list is hidden', () => { + beforeEach(() => { + component.isPopupListHidden = jest.fn().mockReturnValue(true); + }); + + it('should not focus first item', () => { + component.onKeydownHandler(keyboardEvent); + + expect(component.focusFirstItem).not.toHaveBeenCalled(); + }); + + it('should not focus last item', () => { + component.onKeydownHandler(keyboardEvent); + + expect(component.focusLastItem).not.toHaveBeenCalled(); + }); + + it('should ignore escape key handling', () => { + component.onKeydownHandler(keyboardEvent); + + expect(component.hidePopupListAndFocusButton).not.toHaveBeenCalled(); + }); + }); + + describe('popup list is visible', () => { + beforeEach(() => { + component.isPopupListHidden = jest.fn().mockReturnValue(false); + }); + + it('should check if should focus first item', () => { + component.onKeydownHandler(keyboardEvent); + + expect(component.shouldFocusFirstItem).toHaveBeenCalled(); + }); + + it('should focus first item', () => { + component.shouldFocusFirstItem = jest.fn().mockReturnValue(true); + + component.onKeydownHandler(keyboardEvent); + + expect(component.focusFirstItem).toHaveBeenCalled(); + }); + + it('should not focus first item', () => { + component.onKeydownHandler(keyboardEvent); + + expect(component.focusFirstItem).not.toHaveBeenCalled(); + }); + + describe('should not focus first item', () => { + beforeEach(() => { + component.shouldFocusFirstItem = jest.fn().mockReturnValue(false); + }); + + it('should check if should focus last item', () => { + component.onKeydownHandler(keyboardEvent); + + expect(component.shouldFocusLastItem).toHaveBeenCalled(); + }); + + it('should focus last item', () => { + component.shouldFocusLastItem = jest.fn().mockReturnValue(true); + + component.onKeydownHandler(keyboardEvent); + + expect(component.focusLastItem).toHaveBeenCalled(); + }); + + it('should not focus last item', () => { + component.onKeydownHandler(keyboardEvent); + + expect(component.focusLastItem).not.toHaveBeenCalled(); + }); + + describe('should not focus last item', () => { + beforeEach(() => { + component.shouldFocusFirstItem = jest.fn().mockReturnValue(false); + component.shouldFocusLastItem = jest.fn().mockReturnValue(false); + }); + + it('should check for escape key', () => { + component.onKeydownHandler(keyboardEvent); + + expect(component.isEscapeKey).toHaveBeenCalled(); + }); + + it('should hide popup list', () => { + component.isEscapeKey = jest.fn().mockReturnValue(true); + + component.onKeydownHandler(keyboardEvent); + + expect(component.hidePopupListAndFocusButton).toHaveBeenCalled(); + }); + + it('should not hide popup list', () => { + component.onKeydownHandler(keyboardEvent); + + expect(component.hidePopupListAndFocusButton).not.toHaveBeenCalled(); + }); + }); + }); + }); + }); +}); diff --git a/alfa-client/libs/design-system/src/lib/popup/popup/popup.component.ts b/alfa-client/libs/design-system/src/lib/popup/popup/popup.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..da4cb9da07265f7e0b37d5f5d5efa7b3b2def16e --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/popup/popup/popup.component.ts @@ -0,0 +1,139 @@ +import { CommonModule } from '@angular/common'; +import { Component, ElementRef, HostListener, Input, ViewChild } from '@angular/core'; +import { first, last } from 'lodash-es'; +import { twMerge } from 'tailwind-merge'; + +@Component({ + selector: 'ods-popup', + standalone: true, + imports: [CommonModule], + template: `<div class="relative w-fit"> + <button + class="w-fit outline-2 outline-offset-2 outline-focus" + [ngClass]="[twMerge('w-fit outline-2 outline-offset-2 outline-focus', buttonClass)]" + (click)="toggleClicked()" + [attr.aria-expanded]="showPopupList" + aria-haspopup="true" + [attr.aria-label]="label" + data-test-id="popup-button" + #popupButton + > + <ng-content select="[button]" /> + </button> + <ul + *ngIf="showPopupList" + class="absolute max-h-120 min-w-44 max-w-80 animate-fadeIn overflow-y-auto rounded shadow-lg shadow-grayborder focus:outline-none" + [ngClass]="alignTo === 'left' ? 'right-0' : 'left-0'" + role="dialog" + aria-modal="true" + tabindex="0" + #popupList + > + <ng-content /> + </ul> + </div>`, +}) +export class PopupComponent { + @Input() alignTo: 'left' | 'right' = 'left'; + @Input() label: string = ''; + @Input() buttonClass: string = ''; + + constructor(public ref: ElementRef) {} + + showPopupList: boolean = false; + twMerge = twMerge; + + @ViewChild('popupList') popupListRef: ElementRef<HTMLUListElement>; + @ViewChild('popupButton') popupButtonRef: ElementRef<HTMLButtonElement>; + + @HostListener('document:keydown', ['$event']) + onKeydownHandler(e: KeyboardEvent): void { + if (this.isPopupListHidden()) return; + if (this.shouldFocusFirstItem(e)) this.focusFirstItem(this.popupListRef); + if (this.shouldFocusLastItem(e)) this.focusLastItem(this.popupListRef); + if (this.isEscapeKey(e)) this.hidePopupListAndFocusButton(); + } + + @HostListener('document:click', ['$event']) + onClickHandler(e: MouseEvent): void { + if (this.isPopupListHidden()) return; + if (!this.popupButtonRef.nativeElement.contains(e.target as HTMLElement)) { + this.hidePopupListAndFocusButton(); + } + } + + toggleShowPopupList(): void { + this.showPopupList = !this.showPopupList; + } + + hidePopupListAndFocusButton(): void { + this.showPopupList = false; + this.popupButtonRef.nativeElement.focus(); + } + + toggleClicked(): void { + this.toggleShowPopupList(); + if (this.showPopupList) { + setTimeout(() => this.popupListRef.nativeElement.focus()); + } + } + + isTabKey(e: KeyboardEvent): boolean { + return e.key === 'Tab' && !e.shiftKey; + } + + isShiftTabKey(e: KeyboardEvent): boolean { + return e.key === 'Tab' && Boolean(e.shiftKey); + } + + isEscapeKey(e: KeyboardEvent): boolean { + return e.key === 'Escape'; + } + + isFirstItemFocused(popupList: ElementRef<HTMLElement>, document: Document): boolean { + if (!popupList) return false; + return popupList.nativeElement.firstChild.contains(document.activeElement); + } + + isPopupListFocused(popupList: ElementRef<HTMLElement>, document: Document): boolean { + if (!popupList) return false; + return popupList.nativeElement.isSameNode(document.activeElement); + } + + isLastItemFocused(popupList: ElementRef<HTMLElement>, document: Document): boolean { + if (!popupList) return false; + return popupList.nativeElement.lastChild.contains(document.activeElement); + } + + isPopupListHidden(): boolean { + return !this.showPopupList; + } + + focusFirstItem(popupList: ElementRef<HTMLElement>): void { + const firstItem: HTMLElement = this.getFocusableElement( + first(popupList.nativeElement.children), + ); + setTimeout(() => firstItem.focus()); + } + + focusLastItem(popupList: ElementRef<HTMLElement>): void { + const lastItem: HTMLElement = this.getFocusableElement(last(popupList.nativeElement.children)); + setTimeout(() => lastItem.focus()); + } + + shouldFocusFirstItem(e: KeyboardEvent): boolean { + return this.isTabKey(e) && this.isLastItemFocused(this.popupListRef, document); + } + + shouldFocusLastItem(e: KeyboardEvent): boolean { + return ( + this.isShiftTabKey(e) && + (this.isFirstItemFocused(this.popupListRef, document) || + this.isPopupListFocused(this.popupListRef, document)) + ); + } + + getFocusableElement(element: Element): HTMLElement { + return first(element.querySelectorAll('a[href], button, [tabindex]')) as HTMLElement; + } +} diff --git a/alfa-client/libs/design-system/src/lib/popup/popup/popup.stories.ts b/alfa-client/libs/design-system/src/lib/popup/popup/popup.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..ecde2c16145d2e95ddcd3327d51e003d27f721f6 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/popup/popup/popup.stories.ts @@ -0,0 +1,78 @@ +import { + argsToTemplate, + componentWrapperDecorator, + moduleMetadata, + type Meta, + type StoryObj, +} from '@storybook/angular'; + +import { SaveIconComponent } from '../../icons/save-icon/save-icon.component'; +import { UserIconComponent } from '../../icons/user-icon/user-icon.component'; +import { PopupListItemComponent } from '../popup-list-item/popup-list-item.component'; +import { PopupComponent } from './popup.component'; + +const meta: Meta<PopupComponent> = { + title: 'Popup/Popup', + component: PopupComponent, + decorators: [ + moduleMetadata({ + imports: [PopupComponent, PopupListItemComponent, SaveIconComponent, UserIconComponent], + }), + componentWrapperDecorator((story) => `<div class="flex justify-center mb-32">${story}</div>`), + ], + excludeStories: /.*Data$/, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj<PopupComponent>; + +export const Default: Story = { + args: { alignTo: 'left', label: '', buttonClass: '' }, + argTypes: { + alignTo: { + control: 'select', + options: ['left', 'right'], + table: { + defaultValue: { summary: 'left' }, + }, + }, + buttonClass: { description: 'Tailwind class for button' }, + label: { description: 'Aria-label for button' }, + }, + render: (args) => ({ + props: args, + template: `<ods-popup ${argsToTemplate(args)}> + <ods-user-icon button /> + <ods-popup-list-item caption="Lorem" /> + <ods-popup-list-item caption="Ipsum" /> + <ods-popup-list-item caption="Dolor" /> + </ods-popup>`, + }), +}; + +export const LongText: Story = { + render: (args) => ({ + props: args, + template: `<ods-popup ${argsToTemplate(args)}> + <p button>Trigger popup</p> + <ods-popup-list-item caption="Lorem" /> + <ods-popup-list-item caption="Lorem ipsum dolor sit amet" /> + </ods-popup>`, + }), +}; + +export const ItemsWithIcons: Story = { + render: (args) => ({ + props: args, + template: `<ods-popup ${argsToTemplate(args)}> + <p button>Trigger popup</p> + <ods-popup-list-item caption="Lorem"> + <ods-save-icon icon size="small" /> + </ods-popup-list-item> + <ods-popup-list-item caption="Lorem ipsum dolor sit amet"> + <ods-save-icon icon size="small" /> + </ods-popup-list-item> + </ods-popup>`, + }), +}; diff --git a/alfa-client/libs/design-system/src/lib/tailwind-preset/tailwind.config.js b/alfa-client/libs/design-system/src/lib/tailwind-preset/tailwind.config.js index d9f3fc1776a0f7b225863cc6e7654f3c84539f74..2ad76e5342c66c35ccc434bb8b99b96135c961e7 100644 --- a/alfa-client/libs/design-system/src/lib/tailwind-preset/tailwind.config.js +++ b/alfa-client/libs/design-system/src/lib/tailwind-preset/tailwind.config.js @@ -45,6 +45,9 @@ module.exports = { borderWidth: { 3: '3px', }, + maxHeight: { + 120: '480px', + }, colors: { ozgblue: { 50: 'hsl(200, 100%, 96%)',