diff --git a/projects/angular-ui/src/lib/dropdown-menu/dropdown-menu.component.scss b/projects/angular-ui/src/lib/dropdown-menu/dropdown-menu.component.scss index a048c84f..357d9c84 100644 --- a/projects/angular-ui/src/lib/dropdown-menu/dropdown-menu.component.scss +++ b/projects/angular-ui/src/lib/dropdown-menu/dropdown-menu.component.scss @@ -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; diff --git a/projects/angular-ui/src/lib/dropdown-menu/dropdown-menu.component.ts b/projects/angular-ui/src/lib/dropdown-menu/dropdown-menu.component.ts index 8a1a247f..6dddc036 100644 --- a/projects/angular-ui/src/lib/dropdown-menu/dropdown-menu.component.ts +++ b/projects/angular-ui/src/lib/dropdown-menu/dropdown-menu.component.ts @@ -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); + } } } @@ -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() { @@ -316,11 +333,6 @@ export class BaoDropdownMenuComponent ); } - public focusFirstItem(): void { - this._activeItemIndex = 0; - this._listItems.first.nativeElement.focus(); - } - public open(): void { this.isOpen = true; } @@ -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( @@ -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; + } } } /** @@ -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(); @@ -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 { diff --git a/projects/angular-ui/src/lib/list/list-item.component.html b/projects/angular-ui/src/lib/list/list-item.component.html index 7144844d..bf55ad25 100644 --- a/projects/angular-ui/src/lib/list/list-item.component.html +++ b/projects/angular-ui/src/lib/list/list-item.component.html @@ -11,3 +11,9 @@ > +
+ + +
diff --git a/projects/angular-ui/src/lib/list/list.component.scss b/projects/angular-ui/src/lib/list/list.component.scss index e4c7799b..43618207 100644 --- a/projects/angular-ui/src/lib/list/list.component.scss +++ b/projects/angular-ui/src/lib/list/list.component.scss @@ -123,5 +123,10 @@ $size-xx-small: 1rem; } } } + + > .bao-list-item-actions .bao-button { + margin-left: 2rem; + margin-top: -0.5rem; + } } } diff --git a/projects/angular-ui/src/lib/list/list.component.spec.ts b/projects/angular-ui/src/lib/list/list.component.spec.ts new file mode 100644 index 00000000..e7deaffd --- /dev/null +++ b/projects/angular-ui/src/lib/list/list.component.spec.ts @@ -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; + 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); + }); + }); +}); diff --git a/projects/angular-ui/src/lib/list/tests/list.hostcomponent.spec.ts b/projects/angular-ui/src/lib/list/tests/list.hostcomponent.spec.ts new file mode 100644 index 00000000..1cdeabc1 --- /dev/null +++ b/projects/angular-ui/src/lib/list/tests/list.hostcomponent.spec.ts @@ -0,0 +1,99 @@ +/* + * 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 { Component } from '@angular/core'; + +@Component({ + template: ` + + + + Title +
    +
  • Description 1
  • +
  • Description 2
  • +
+ + + + +
+ + + Title +
    +
  • Description 1
  • +
  • Description 2
  • +
+ + + + +
+
+ ` +}) +export class TestListDropdownMenuComponent {} diff --git a/projects/storybook-angular/src/stories/List/List.stories.ts b/projects/storybook-angular/src/stories/List/List.stories.ts index bad27ee1..e9563329 100644 --- a/projects/storybook-angular/src/stories/List/List.stories.ts +++ b/projects/storybook-angular/src/stories/List/List.stories.ts @@ -3,12 +3,20 @@ * Licensed under the MIT license. * See LICENSE file in the project root for full license information. */ +import { OverlayModule } from '@angular/cdk/overlay'; +import { PortalModule } from '@angular/cdk/portal'; +import { CommonModule } from '@angular/common'; import { moduleMetadata } from '@storybook/angular'; import { Meta, Story } from '@storybook/angular'; import { + BaoAvatarModule, + BaoButtonModule, + BaoCheckboxModule, + BaoDropdownMenuModule, BaoIconModule, BaoListItem, BaoListModule, + BaoRadioModule, BaoTagModule } from 'angular-ui'; @@ -23,7 +31,19 @@ export default { decorators: [ moduleMetadata({ // declarations: [BaoListItem], - imports: [BaoListModule, BaoIconModule, BaoTagModule] + imports: [ + BaoListModule, + BaoIconModule, + BaoTagModule, + CommonModule, + BaoDropdownMenuModule, + BaoButtonModule, + BaoAvatarModule, + BaoCheckboxModule, + BaoRadioModule, + OverlayModule, + PortalModule + ] }) ], component: BaoListItem, @@ -128,7 +148,7 @@ export const SimpleListWithTagAndIcon: Story = args => ({ Title - Label + Non vérifié ` @@ -163,7 +183,7 @@ export const SimpleListWithDescription: Story = args => ({ Title - Label + Non vérifié
Description 1
Description 2
@@ -206,7 +226,7 @@ export const SimpleListWithInlineDescription: Story = args => ({
  • Description 1
  • Description 2
  • - Label + Non vérifié
    ` @@ -215,3 +235,166 @@ SimpleListWithInlineDescription.storyName = 'Simple list - Inline Description'; SimpleListWithInlineDescription.args = { ...Primary.args }; + +export const SimpleListWithDropdownMenu: Story = args => ({ + props: args, + template: ` + + + + Title +
      +
    • Description 1
    • +
    • Description 2
    • +
    + Label + + + + +
    + + + Title +
      +
    • Description 1
    • +
    • Description 2
    • +
    + + + Non vérifié + + + + + +
    + + + Title +
      +
    • Description 1
    • +
    • Description 2
    • +
    + + + + +
    +
    + ` +}); +SimpleListWithDropdownMenu.storyName = 'Simple list - Dropdown menu'; +SimpleListWithDropdownMenu.args = { + ...Primary.args +}; + +export const SimpleListWithActionButton: Story = args => ({ + props: args, + template: ` + + + + Title +
      +
    • Description 1
    • +
    • Description 2
    • +
    + Label + +
    + + + Title +
      +
    • Description 1
    • +
    • Description 2
    • +
    + + + Non vérifié + + +
    + + + Title +
      +
    • Description 1
    • +
    • Description 2
    • +
    + +
    +
    + ` +}); +SimpleListWithActionButton.storyName = 'Simple list - Action button'; +SimpleListWithActionButton.args = { + ...Primary.args +};