Skip to content

Commit

Permalink
feat: crmmon-7566 - Add a dropdown menu as a list item option (#214)
Browse files Browse the repository at this point in the history
Signed-off-by: Tola <tola.sam@montreal.ca>
  • Loading branch information
TolaSam authored Aug 29, 2024
1 parent 1bbe5eb commit 56104c3
Show file tree
Hide file tree
Showing 7 changed files with 484 additions and 52 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,14 @@ $overlay-max-width: 32rem !default;
background: $white;
box-shadow: 0 0.5rem 2rem 0 rgba(0, 0, 0, 0.1);
border-radius: 0.25rem;
margin-top: 0.25rem;
margin-bottom: 0.25rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;

&:focus-visible {
outline: none;
}
.bao-overlay-transparent-backdrop {
background-color: $transparent;
display: none;
Expand Down
126 changes: 78 additions & 48 deletions projects/angular-ui/src/lib/dropdown-menu/dropdown-menu.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,18 +269,22 @@ export class BaoDropdownMenuComponent
@HostListener('window:keyup.arrowup')
upKeyEvent() {
if (this.isOpen) {
const index = isNaN(this._activeItemIndex) ? 0 : this._activeItemIndex;
const index = isNaN(this._activeItemIndex) ? -1 : this._activeItemIndex;
const nextIndex = this.getNextActivableItemIndex(index, false);
this.focusNextItem(nextIndex);
if (nextIndex > -1) {
this.focusNextItem(nextIndex);
}
}
}

@HostListener('window:keyup.arrowdown')
downKeyEvent() {
if (this.isOpen) {
const index = isNaN(this._activeItemIndex) ? 0 : this._activeItemIndex;
const index = isNaN(this._activeItemIndex) ? -1 : this._activeItemIndex;
const nextIndex = this.getNextActivableItemIndex(index, true);
this.focusNextItem(nextIndex);
if (nextIndex > -1) {
this.focusNextItem(nextIndex);
}
}
}

Expand All @@ -294,6 +298,19 @@ export class BaoDropdownMenuComponent
}
}

@HostListener('window:keyup.tab')
tabUpKeyEvent() {
if (this.isOpen) {
this._activeItemIndex = this._listItems.reduce((acc, element, index) => {
if (element.nativeElement === document.activeElement) {
acc = index;
}

return acc;
}, -1);
}
}

/** Prevents focus to be lost when SHIFT + TAB has reached beginning of menu */
@HostListener('window:keydown.shift.tab')
shiftTabKeyEvent() {
Expand All @@ -316,11 +333,6 @@ export class BaoDropdownMenuComponent
);
}

public focusFirstItem(): void {
this._activeItemIndex = 0;
this._listItems.first.nativeElement.focus();
}

public open(): void {
this.isOpen = true;
}
Expand All @@ -329,6 +341,11 @@ export class BaoDropdownMenuComponent
this.isOpen = false;
}

public focus(): void {
this._activeItemIndex = -1;
this._menuContent.nativeElement.focus();
}

/** Move the aria-current attribute to new active page */
public setNavigationAttribute(activePageElement: HTMLElement): void {
const previousActivePage = this._listItems.find(
Expand Down Expand Up @@ -364,40 +381,62 @@ export class BaoDropdownMenuComponent
*/
private getNextActivableItemIndex(
currentIndex: number,
isDown: boolean,
isBackward = false
isDown: boolean
): number {
if (!this._listItems.get(currentIndex).disabled) {
if (!this.canMove(currentIndex, isDown)) {
return currentIndex;
const init: number[] = [];
// Get all the activable indexes
const activableIndexes = this._listItems.reduce((acc, element, index) => {
if (!element.disabled) {
acc = [...acc, index];
}
} else {
if (!this.canMove(currentIndex, isDown)) {
const previousIndex = isDown ? currentIndex - 1 : currentIndex + 1;
return this.getNextActivableItemIndex(previousIndex, isDown, true);

return acc;
}, init);

if (activableIndexes.length) {
if (isDown) {
// Select the first enabled element
if (currentIndex === -1) {
return activableIndexes[0];
}

// Select the only enabled element
if (activableIndexes.length === 1) {
return activableIndexes[0];
}

// Stay on the last enabled element
if (currentIndex === activableIndexes[activableIndexes.length - 1]) {
return currentIndex;
}

// Select the next enabled element
return activableIndexes.find(index => index > currentIndex);
}
}
const nextIndex = isDown ? currentIndex + 1 : currentIndex - 1;
if (this._listItems.get(nextIndex).disabled) {
if (isBackward) {
return currentIndex;

const isUp = !isDown;
if (isUp) {
// Do nothing whenever nothing is selected
if (currentIndex === -1) {
return currentIndex;
}

// Select the only enabled element
if (activableIndexes.length === 1) {
return activableIndexes[0];
}

// Stay on the first enabled element
if (currentIndex === activableIndexes[0]) {
return currentIndex;
}

// Select the above enabled element
return activableIndexes.reverse().find(index => index < currentIndex);
}
return this.getNextActivableItemIndex(nextIndex, isDown);
}
return nextIndex;
}

/**
* Finds if focus has reached end or beginning of list
* @param currentIndex List item index which currently has focus
* @param isDown Whether the navigation is going in the down direction or not
* @returns Can focus move to next item or not
*/
private canMove(currentIndex: number, isDown: boolean): boolean {
return !(
(currentIndex == 0 && !isDown) ||
(currentIndex == this._listItems.length - 1 && isDown)
);
return -1;
}
}
}
/**
Expand Down Expand Up @@ -431,16 +470,6 @@ export class BaoDropdownMenuTrigger implements AfterViewInit, OnDestroy {
}
}

/** Enter key event triggers click event which opens menu,
* then focus is put on first item in the menu */
@HostListener('window:keyup.enter', ['$event'])
enterKeyEvent(event: KeyboardEvent) {
if (this._isMenuOpen && document.activeElement === this.nativeElement) {
event.stopImmediatePropagation();
this.menu.focusFirstItem();
}
}

@HostListener('click')
onClick() {
this.toggleMenu();
Expand Down Expand Up @@ -485,6 +514,7 @@ export class BaoDropdownMenuTrigger implements AfterViewInit, OnDestroy {
overlayRef.attach(this.menu.menuPortal);
this._isMenuOpen = true;
this.menu.open();
this.menu.focus();
}

private createOverlay(): OverlayRef {
Expand Down
6 changes: 6 additions & 0 deletions projects/angular-ui/src/lib/list/list-item.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,9 @@
></ng-content>
</div>
</div>
<div class="bao-list-item-actions">
<ng-content
select="bao-button, [bao-button], bao-dropdown-menu, [bao-dropdown-menu]"
>
</ng-content>
</div>
5 changes: 5 additions & 0 deletions projects/angular-ui/src/lib/list/list.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -123,5 +123,10 @@ $size-xx-small: 1rem;
}
}
}

> .bao-list-item-actions .bao-button {
margin-left: 2rem;
margin-top: -0.5rem;
}
}
}
103 changes: 103 additions & 0 deletions projects/angular-ui/src/lib/list/list.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
* Copyright (c) 2024 Ville de Montreal. All rights reserved.
* Licensed under the MIT license.
* See LICENSE file in the project root for full license information.
*/
import { OverlayModule } from '@angular/cdk/overlay';
import { DebugElement } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { BaoListModule } from '.';
import { BaoButtonModule } from '../button';
import { BaoDropdownMenuModule } from '../dropdown-menu';
import { BaoIconModule } from '../icon';
import { TestListDropdownMenuComponent } from './tests/list.hostcomponent.spec';

describe('BaoList', () => {
describe('TestListDropdownMenuComponent', () => {
let fixture: ComponentFixture<TestListDropdownMenuComponent>;
let TestListDropdownMenuComponentElement: DebugElement;

beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [TestListDropdownMenuComponent],
imports: [
OverlayModule,
BaoListModule,
BaoDropdownMenuModule,
BaoButtonModule,
BaoIconModule
]
});
return TestBed.compileComponents();
}));

beforeEach(() => {
fixture = TestBed.createComponent(TestListDropdownMenuComponent);
TestListDropdownMenuComponentElement = fixture.debugElement;
});

it('should have a list', () => {
const element = TestListDropdownMenuComponentElement.queryAll(
By.css('.bao-list')
);

expect(element).toBeTruthy();
});

it('should have an icon', () => {
const element = TestListDropdownMenuComponentElement.queryAll(
By.css('.bao-list-item-header .bao-icon')
);

expect(element).toBeTruthy();
});

it('should have a title', () => {
const element = TestListDropdownMenuComponentElement.queryAll(
By.css(
'.bao-list-item-content .bao-list-item-text .bao-list-item-title'
)
);

expect(element).toBeTruthy();
});

it('should have a description', () => {
const element = TestListDropdownMenuComponentElement.queryAll(
By.css(
'.bao-list-item-content .bao-list-item-text .bao-list-item-description li'
)
);

expect(element).toBeTruthy();
});

it('should have a tag', () => {
const element = TestListDropdownMenuComponentElement.queryAll(
By.css('.bao-list-item-content .bao-list-item-tag .bao-tag')
);

expect(element).toBeTruthy();
});

it('should have a dropdown menu within the list item', () => {
const element = TestListDropdownMenuComponentElement.queryAll(
By.css('.bao-list-item .bao-dropdown-menu-container')
);

expect(element).toBeTruthy();
});

it('should have as many dropdown menus as the list items', () => {
const listItemElement = TestListDropdownMenuComponentElement.queryAll(
By.css('.bao-list-item')
);
const dropDownMenuElement = TestListDropdownMenuComponentElement.queryAll(
By.css('.bao-dropdown-menu-container')
);

expect(listItemElement.length).toEqual(dropDownMenuElement.length);
});
});
});
Loading

0 comments on commit 56104c3

Please sign in to comment.