From 2866813ddea6456e561b5ee9c5539d4685c7f840 Mon Sep 17 00:00:00 2001 From: Trevor Burch Date: Thu, 6 Feb 2025 10:45:46 -0500 Subject: [PATCH 01/20] feat(components/flyout): tokenize flyout styles (#3098) [AB#3049524](https://dev.azure.com/blackbaud/f565481a-7bc9-4083-95d5-4f953da6d499/_workitems/edit/3049524) --- .../src/e2e/flyout.component.cy.ts | 2 +- apps/e2e/flyout-storybook/project.json | 9 +- .../flyout/flyout-iterator.component.html | 8 +- .../flyout/flyout-iterator.component.scss | 25 +- .../lib/modules/flyout/flyout.component.html | 19 +- .../lib/modules/flyout/flyout.component.scss | 308 +++++++++++++++--- libs/components/theme/package.json | 2 +- package-lock.json | 9 +- package.json | 2 +- 9 files changed, 314 insertions(+), 70 deletions(-) diff --git a/apps/e2e/flyout-storybook-e2e/src/e2e/flyout.component.cy.ts b/apps/e2e/flyout-storybook-e2e/src/e2e/flyout.component.cy.ts index c38cb0aae1..aae09f377f 100644 --- a/apps/e2e/flyout-storybook-e2e/src/e2e/flyout.component.cy.ts +++ b/apps/e2e/flyout-storybook-e2e/src/e2e/flyout.component.cy.ts @@ -50,5 +50,5 @@ describe('flyout-storybook', () => { }); }); }); - }); + }, true); }); diff --git a/apps/e2e/flyout-storybook/project.json b/apps/e2e/flyout-storybook/project.json index f54b402c2a..dbe10717ec 100644 --- a/apps/e2e/flyout-storybook/project.json +++ b/apps/e2e/flyout-storybook/project.json @@ -18,7 +18,8 @@ "styles": [ "apps/e2e/flyout-storybook/src/styles.scss", "libs/components/theme/src/lib/styles/sky.scss", - "libs/components/theme/src/lib/styles/themes/modern/styles.scss" + "libs/components/theme/src/lib/styles/themes/modern/styles.scss", + "node_modules/@blackbaud/skyux-design-tokens/scss/blackbaud.css" ], "scripts": [] }, @@ -88,7 +89,8 @@ "compodoc": false, "styles": [ "libs/components/theme/src/lib/styles/sky.scss", - "libs/components/theme/src/lib/styles/themes/modern/styles.scss" + "libs/components/theme/src/lib/styles/themes/modern/styles.scss", + "node_modules/@blackbaud/skyux-design-tokens/scss/blackbaud.css" ] }, "configurations": { @@ -108,7 +110,8 @@ "compodoc": false, "styles": [ "libs/components/theme/src/lib/styles/sky.scss", - "libs/components/theme/src/lib/styles/themes/modern/styles.scss" + "libs/components/theme/src/lib/styles/themes/modern/styles.scss", + "node_modules/@blackbaud/skyux-design-tokens/scss/blackbaud.css" ] }, "configurations": { diff --git a/libs/components/flyout/src/lib/modules/flyout/flyout-iterator.component.html b/libs/components/flyout/src/lib/modules/flyout/flyout-iterator.component.html index f9fd0998b9..ba21f8ec9b 100644 --- a/libs/components/flyout/src/lib/modules/flyout/flyout-iterator.component.html +++ b/libs/components/flyout/src/lib/modules/flyout/flyout-iterator.component.html @@ -7,8 +7,8 @@ " [disabled]="previousButtonDisabled" [skyThemeClass]="{ - 'sky-btn-default sky-margin-inline-compact': 'default', - 'sky-btn-icon-borderless sky-margin-inline-sm': 'modern' + 'sky-btn-default': 'default', + 'sky-btn-icon-borderless': 'modern' }" (click)="onIteratorPreviousClick()" > @@ -21,8 +21,8 @@ [attr.aria-label]="'skyux_flyout_iterator_next_button' | skyLibResources" [disabled]="nextButtonDisabled" [skyThemeClass]="{ - 'sky-btn-default sky-margin-inline-compact': 'default', - 'sky-btn-icon-borderless sky-margin-inline-sm': 'modern' + 'sky-btn-default': 'default', + 'sky-btn-icon-borderless': 'modern' }" (click)="onIteratorNextClick()" > diff --git a/libs/components/flyout/src/lib/modules/flyout/flyout-iterator.component.scss b/libs/components/flyout/src/lib/modules/flyout/flyout-iterator.component.scss index bffca7b8c5..c515a16c21 100644 --- a/libs/components/flyout/src/lib/modules/flyout/flyout-iterator.component.scss +++ b/libs/components/flyout/src/lib/modules/flyout/flyout-iterator.component.scss @@ -1,7 +1,22 @@ -@use 'libs/components/theme/src/lib/styles/mixins' as mixins; +@use 'libs/components/theme/src/lib/styles/variables' as *; +@use 'libs/components/theme/src/lib/styles/compat-tokens-mixins' as compatMixins; -@include mixins.sky-theme-modern { - .sky-flyout-iterators { - display: inline; - } +@include compatMixins.sky-default-overrides('.sky-flyout-iterators') { + --sky-override-flyout-iterator-margin-right: #{$sky-margin-inline-compact}; + --sky-override-flyout-iterators-display: block; +} + +@include compatMixins.sky-modern-overrides('.sky-flyout-iterators') { + --sky-override-flyout-iterator-margin-right: var(--sky-space-inline-s); +} + +.sky-btn { + margin-right: var( + --sky-override-flyout-iterator-margin-right, + var(--sky-space-gap-action_group-m) + ); +} + +.sky-flyout-iterators { + display: var(--sky-override-flyout-iterators-display, inline); } diff --git a/libs/components/flyout/src/lib/modules/flyout/flyout.component.html b/libs/components/flyout/src/lib/modules/flyout/flyout.component.html index cd183bfbf9..ceb01087f6 100644 --- a/libs/components/flyout/src/lib/modules/flyout/flyout.component.html +++ b/libs/components/flyout/src/lib/modules/flyout/flyout.component.html @@ -30,15 +30,12 @@
} @else if (template) {
-
+
diff --git a/libs/components/inline-form/src/lib/modules/inline-form/inline-form.component.scss b/libs/components/inline-form/src/lib/modules/inline-form/inline-form.component.scss index 49e1d60ed9..7e5fcc03d8 100644 --- a/libs/components/inline-form/src/lib/modules/inline-form/inline-form.component.scss +++ b/libs/components/inline-form/src/lib/modules/inline-form/inline-form.component.scss @@ -1,39 +1,58 @@ @use 'libs/components/theme/src/lib/styles/mixins' as mixins; @use 'libs/components/theme/src/lib/styles/variables' as *; +@use 'libs/components/theme/src/lib/styles/compat-tokens-mixins' as compatMixins; + +@include compatMixins.sky-default-overrides('.sky-inline-form') { + --sky-override-inline-form-background-color: #{$sky-background-color-neutral-light}; + --sky-override-inline-form-border: 1px solid + #{$sky-border-color-neutral-medium}; + --sky-override-inline-form-box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.3); + --sky-override-inline-form-button-margin-right: #{$sky-margin-stacked-compact}; + --sky-override-inline-form-padding: #{$sky-padding-even-default}; + --sky-override-inline-form-footer-margin-top: #{$sky-margin-stacked-separate}; +} + +@include compatMixins.sky-modern-overrides('.sky-inline-form') { + --sky-override-inline-form-box-shadow: var(--sky-elevation-overflow); + --sky-override-inline-form-border: none; +} .sky-inline-form { - background: $sky-background-color-neutral-light; - border: 1px solid $sky-border-color-neutral-medium; - padding: $sky-padding-even-default; + // The background rule can be removed when default theme support is dropped as it comes from the `sky-box` class. + background: var( + --sky-override-inline-form-background-color, + var(--sky-color-background-container-base) + ); + // Box shadow and border rules can be removed when only v2 modern support remains as it will come from the `sky-elevation-1-bordered` class. + border: var( + --sky-override-inline-form-border, + var(--sky-border-style-elevation) var(--sky-border-width-container-base) + var(--sky-color-border-container-base) + ); + box-shadow: var( + --sky-override-inline-form-box-shadow, + var(--sky-elevation-raised-100) + ); + // Padding rule can be removed when default is removed as it will come from the `sky-padding-even-md` class + padding: var( + --sky-override-inline-form-padding, + var(--sky-space-inset-balanced-m) + ); width: 100%; .sky-inline-form-footer { - margin-top: $sky-margin-stacked-separate; + margin-top: var( + --sky-override-inline-form-footer-margin-top, + var(--sky-space-gap-form-xl) + ); button { - margin: 0 $sky-margin-stacked-compact 0 0; + margin: 0 + var( + --sky-override-inline-form-button-margin-right, + var(--sky-space-gap-action_group-m) + ) + 0 0; } } } - -@include mixins.sky-theme-modern { - .sky-inline-form { - background-color: #fff; - border: none; - padding: $sky-theme-modern-padding-even-md; - - .sky-inline-form-footer { - margin-top: $sky-theme-modern-margin-stacked-xl; - - button { - margin-right: $sky-theme-modern-margin-inline-sm; - } - } - } -} - -@include mixins.sky-theme-modern-dark { - .sky-inline-form { - background-color: $sky-theme-modern-mode-dark-background-color-elevation-3; - } -} From 42888cfe6775edea1b36797e321edfc251337895 Mon Sep 17 00:00:00 2001 From: Trevor Burch Date: Thu, 6 Feb 2025 13:04:31 -0500 Subject: [PATCH 05/20] feat(components/errors): tokenize error styles (#3123) [AB#3049518](https://dev.azure.com/blackbaud/f565481a-7bc9-4083-95d5-4f953da6d499/_workitems/edit/3049518) --- .../src/e2e/error.component.cy.ts | 2 +- apps/e2e/errors-storybook/project.json | 9 +++++--- .../lib/modules/error/error.component.scss | 21 ++++++++++++------- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/apps/e2e/errors-storybook-e2e/src/e2e/error.component.cy.ts b/apps/e2e/errors-storybook-e2e/src/e2e/error.component.cy.ts index 9214c50418..19a9d7699b 100644 --- a/apps/e2e/errors-storybook-e2e/src/e2e/error.component.cy.ts +++ b/apps/e2e/errors-storybook-e2e/src/e2e/error.component.cy.ts @@ -35,5 +35,5 @@ describe('errors-storybook - error', () => { }); }); }); - }); + }, true); }); diff --git a/apps/e2e/errors-storybook/project.json b/apps/e2e/errors-storybook/project.json index 6412260cd0..82947cc2b1 100644 --- a/apps/e2e/errors-storybook/project.json +++ b/apps/e2e/errors-storybook/project.json @@ -18,7 +18,8 @@ "styles": [ "apps/e2e/errors-storybook/src/styles.scss", "libs/components/theme/src/lib/styles/sky.scss", - "libs/components/theme/src/lib/styles/themes/modern/styles.scss" + "libs/components/theme/src/lib/styles/themes/modern/styles.scss", + "node_modules/@blackbaud/skyux-design-tokens/scss/blackbaud.css" ], "scripts": [] }, @@ -88,7 +89,8 @@ "compodoc": false, "styles": [ "libs/components/theme/src/lib/styles/sky.scss", - "libs/components/theme/src/lib/styles/themes/modern/styles.scss" + "libs/components/theme/src/lib/styles/themes/modern/styles.scss", + "node_modules/@blackbaud/skyux-design-tokens/scss/blackbaud.css" ] }, "configurations": { @@ -108,7 +110,8 @@ "compodoc": false, "styles": [ "libs/components/theme/src/lib/styles/sky.scss", - "libs/components/theme/src/lib/styles/themes/modern/styles.scss" + "libs/components/theme/src/lib/styles/themes/modern/styles.scss", + "node_modules/@blackbaud/skyux-design-tokens/scss/blackbaud.css" ] }, "configurations": { diff --git a/libs/components/errors/src/lib/modules/error/error.component.scss b/libs/components/errors/src/lib/modules/error/error.component.scss index 8929a75a91..a0a0106d27 100644 --- a/libs/components/errors/src/lib/modules/error/error.component.scss +++ b/libs/components/errors/src/lib/modules/error/error.component.scss @@ -1,12 +1,25 @@ @use 'libs/components/theme/src/lib/styles/mixins' as mixins; @use 'libs/components/theme/src/lib/styles/variables' as *; +@use 'libs/components/theme/src/lib/styles/compat-tokens-mixins' as compatMixins; + +@include compatMixins.sky-default-overrides('.sky-error') { + --sky-override-error-margin-top: 0; + --sky-override-error-padding-top: 60px; +} .sky-error { display: flex; flex-direction: column; justify-content: center; text-align: center; - padding-top: 60px; + padding-top: var( + --sky-override-error-padding-top, + var(--sky-space-stacked-xxl) + ); + margin-top: var( + --sky-override-error-margin-top, + var(--sky-space-stacked-xxl) + ); .sky-error-description span { white-space: pre-wrap; @@ -24,12 +37,6 @@ } } -@include mixins.sky-theme-modern { - .sky-error { - margin-top: $sky-theme-modern-margin-stacked-xxl; - } -} - .sky-error-broken-image { height: 110px; width: 170px; From 14addddc2dc726efe72335448c8b88ca57a738e4 Mon Sep 17 00:00:00 2001 From: Trevor Burch Date: Thu, 6 Feb 2025 15:14:43 -0500 Subject: [PATCH 06/20] feat(components/split-view): tokenize split view styles (#3119) [AB#3049558](https://dev.azure.com/blackbaud/f565481a-7bc9-4083-95d5-4f953da6d499/_workitems/edit/3049558) --- .../src/e2e/split-view.component.cy.ts | 2 +- apps/e2e/split-view-storybook/project.json | 9 ++- .../split-view-drawer.component.scss | 57 ++++++++++++------- ...split-view-workspace-footer.component.html | 2 +- ...split-view-workspace-footer.component.scss | 25 +++++++- ...split-view-workspace-header.component.scss | 23 +++++++- .../split-view-workspace.component.scss | 17 +++--- .../split-view/split-view.component.scss | 19 +++++-- 8 files changed, 112 insertions(+), 42 deletions(-) diff --git a/apps/e2e/split-view-storybook-e2e/src/e2e/split-view.component.cy.ts b/apps/e2e/split-view-storybook-e2e/src/e2e/split-view.component.cy.ts index fe837514e1..38403c2450 100644 --- a/apps/e2e/split-view-storybook-e2e/src/e2e/split-view.component.cy.ts +++ b/apps/e2e/split-view-storybook-e2e/src/e2e/split-view.component.cy.ts @@ -34,5 +34,5 @@ describe('split-view-storybook', () => { }, ); }); - }); + }, true); }); diff --git a/apps/e2e/split-view-storybook/project.json b/apps/e2e/split-view-storybook/project.json index 2708891105..d8312a8fac 100644 --- a/apps/e2e/split-view-storybook/project.json +++ b/apps/e2e/split-view-storybook/project.json @@ -18,7 +18,8 @@ "styles": [ "apps/e2e/split-view-storybook/src/styles.scss", "libs/components/theme/src/lib/styles/sky.scss", - "libs/components/theme/src/lib/styles/themes/modern/styles.scss" + "libs/components/theme/src/lib/styles/themes/modern/styles.scss", + "node_modules/@blackbaud/skyux-design-tokens/scss/blackbaud.css" ], "scripts": [] }, @@ -88,7 +89,8 @@ "compodoc": false, "styles": [ "libs/components/theme/src/lib/styles/sky.scss", - "libs/components/theme/src/lib/styles/themes/modern/styles.scss" + "libs/components/theme/src/lib/styles/themes/modern/styles.scss", + "node_modules/@blackbaud/skyux-design-tokens/scss/blackbaud.css" ] }, "configurations": { @@ -108,7 +110,8 @@ "compodoc": false, "styles": [ "libs/components/theme/src/lib/styles/sky.scss", - "libs/components/theme/src/lib/styles/themes/modern/styles.scss" + "libs/components/theme/src/lib/styles/themes/modern/styles.scss", + "node_modules/@blackbaud/skyux-design-tokens/scss/blackbaud.css" ] }, "configurations": { diff --git a/libs/components/split-view/src/lib/modules/split-view/split-view-drawer.component.scss b/libs/components/split-view/src/lib/modules/split-view/split-view-drawer.component.scss index 561b290f84..2e272ff32e 100644 --- a/libs/components/split-view/src/lib/modules/split-view/split-view-drawer.component.scss +++ b/libs/components/split-view/src/lib/modules/split-view/split-view-drawer.component.scss @@ -1,8 +1,22 @@ @use 'libs/components/theme/src/lib/styles/mixins' as mixins; @use 'libs/components/theme/src/lib/styles/variables' as *; +@use 'libs/components/theme/src/lib/styles/compat-tokens-mixins' as compatMixins; + +@include compatMixins.sky-default-overrides('.sky-split-view-drawer') { + --sky-override-split-view-drawer-background-color: #ffffff; + --sky-override-split-view-drawer-border-right: 1px solid + #{$sky-border-color-neutral-medium}; + --sky-override-split-view-resize-handle-border-focus: none; + --sky-override-split-view-resize-handle-box-shadow-focus: none; + --sky-override-split-view-resize-handle-outline-focus: auto; + --sky-override-split-view-resize-handle-width: 14px; +} .sky-split-view-drawer { - background: #ffffff; + background: var( + --sky-override-split-view-drawer-background-color, + transparent + ); overflow: auto; height: 100%; width: 100%; @@ -10,7 +24,10 @@ @include mixins.sky-host-responsive-container-sm-min() { .sky-split-view-drawer { - border-right: 1px solid $sky-border-color-neutral-medium; + border-right: var( + --sky-override-split-view-drawer-border-right, + var(--sky-border-width-divider) solid var(--sky-color-border-divider) + ); } } @@ -18,7 +35,10 @@ -webkit-appearance: none; -moz-appearance: none; height: 100%; - width: 14px; + width: var( + --sky-override-split-view-resize-handle-width, + var(--sky-size-width-resize_handle) + ); position: absolute; z-index: 999; cursor: ew-resize; @@ -28,6 +48,20 @@ display: block; top: 0; bottom: 0; + transition: box-shadow $sky-transition-time-short; + + &:focus { + border: var( + --sky-override-split-view-resize-handle-border-focus, + solid var(--sky-border-width-action-focus) + var(--sky-color-border-action-tertiary-focus) + ); + box-shadow: var( + --sky-override-split-view-resize-handle-box-shadow-focus, + var(--sky-elevation-focus) + ); + outline: var(--sky-override-split-view-resize-handle-outline-focus, none); + } &::-moz-range-thumb, &::-moz-range-track { @@ -64,20 +98,3 @@ display: none; } } - -@include mixins.sky-theme-modern { - .sky-split-view-drawer { - background: transparent; - } - - .sky-split-view-resize-handle { - transition: box-shadow $sky-transition-time-short; - - &:focus { - border: solid 2px $sky-theme-modern-background-color-primary-dark; - box-shadow: $sky-theme-modern-elevation-3-shadow-size - $sky-theme-modern-elevation-3-shadow-color; - outline: none; - } - } -} diff --git a/libs/components/split-view/src/lib/modules/split-view/split-view-workspace-footer.component.html b/libs/components/split-view/src/lib/modules/split-view/split-view-workspace-footer.component.html index 3ce472b2d8..12900bdbe4 100644 --- a/libs/components/split-view/src/lib/modules/split-view/split-view-workspace-footer.component.html +++ b/libs/components/split-view/src/lib/modules/split-view/split-view-workspace-footer.component.html @@ -1,3 +1,3 @@ -
{{ 'skyux_dropdown_context_menu_with_content_descriptor_el_default_label' diff --git a/libs/components/popovers/src/lib/modules/dropdown/dropdown.component.spec.ts b/libs/components/popovers/src/lib/modules/dropdown/dropdown.component.spec.ts index 1ac0f59ceb..c0aaa08650 100644 --- a/libs/components/popovers/src/lib/modules/dropdown/dropdown.component.spec.ts +++ b/libs/components/popovers/src/lib/modules/dropdown/dropdown.component.spec.ts @@ -30,1222 +30,1299 @@ import { SkyDropdownFixturesModule } from './fixtures/dropdown-fixtures.module'; import { DropdownFixtureComponent } from './fixtures/dropdown.component.fixture'; import { SkyDropdownMessageType } from './types/dropdown-message-type'; -describe('Dropdown component', function () { - let fixture: ComponentFixture; - let contentInfoProvider: SkyContentInfoProvider; - let mockThemeService: { - settingsChange: BehaviorSubject; - }; - let idService: SkyIdService; - - //#region helpers - - function getButtonElement(): HTMLButtonElement | null { - return fixture.nativeElement.querySelector('.sky-dropdown-button'); - } +describe('Dropdown component', () => { + function runTests(useCustomTrigger: boolean): void { + let fixture: ComponentFixture; + let contentInfoProvider: SkyContentInfoProvider; + let mockThemeService: { + settingsChange: BehaviorSubject; + }; + let idService: SkyIdService; - function getMenuContainerElement(): HTMLElement | null { - return document.querySelector('.sky-dropdown-menu-container'); - } + //#region helpers - function getMenuElement(): Element | null { - const container = getMenuContainerElement(); - if (!container) { - return container; + function getButtonElement(): HTMLButtonElement | null { + return fixture.nativeElement.querySelector( + useCustomTrigger ? '.custom-trigger' : '.sky-dropdown-button', + ); } - return container.querySelector('.sky-dropdown-menu'); - } - - function getMenuItems(): NodeListOf | undefined { - return getMenuElement()?.querySelectorAll('.sky-dropdown-item'); - } - - function getFirstMenuItem(): Element | undefined { - return getMenuItems()?.item(0); - } + function getMenuContainerElement(): HTMLElement | null { + return document.querySelector('.sky-dropdown-menu-container'); + } - function verifyActiveMenuItemByIndex(index: number): void { - const menuItems = fixture.componentInstance.dropdownItemRefs?.toArray(); - - menuItems?.forEach((item: SkyDropdownItemComponent, i: number) => { - if (i === index) { - expect(item.isActive).toEqual(true); - expect( - item.elementRef.nativeElement.querySelector('.sky-dropdown-item'), - ).toHaveCssClass('sky-dropdown-item-active'); - } else { - expect(item.isActive).toEqual(false); - expect( - item.elementRef.nativeElement.querySelector('.sky-dropdown-item'), - ).not.toHaveCssClass('sky-dropdown-item-active'); + function getMenuElement(): Element | null { + const container = getMenuContainerElement(); + if (!container) { + return container; } - }); - } - - function isMenuItemFocused(index: number): boolean { - const menuItemButtons = document.querySelectorAll( - '.sky-dropdown-item button', - ); - return isElementFocused(menuItemButtons[index]); - } - function isElementFocused(elem: Element | undefined | null): boolean { - return !!elem && elem === document.activeElement; - } + return container.querySelector('.sky-dropdown-menu'); + } - function isElementVisible(elem: Element | undefined | null): boolean { - return !!elem && getComputedStyle(elem).visibility !== 'hidden'; - } + function getMenuItems(): NodeListOf | undefined { + return getMenuElement()?.querySelectorAll('.sky-dropdown-item'); + } - /** - * Multiple ticks are needed to accommodate setTimeout and observable streams. - */ - function detectChangesFakeAsync(): void { - fixture.detectChanges(); - tick(); - fixture.detectChanges(); - tick(); - } + function getFirstMenuItem(): Element | undefined { + return getMenuItems()?.item(0); + } - //#endregion + function verifyActiveMenuItemByIndex(index: number): void { + const menuItems = fixture.componentInstance.dropdownItemRefs?.toArray(); + + menuItems?.forEach((item: SkyDropdownItemComponent, i: number) => { + if (i === index) { + expect(item.isActive).toEqual(true); + expect( + item.elementRef.nativeElement.querySelector('.sky-dropdown-item'), + ).toHaveCssClass('sky-dropdown-item-active'); + } else { + expect(item.isActive).toEqual(false); + expect( + item.elementRef.nativeElement.querySelector('.sky-dropdown-item'), + ).not.toHaveCssClass('sky-dropdown-item-active'); + } + }); + } - beforeEach(() => { - mockThemeService = { - settingsChange: new BehaviorSubject({ - currentSettings: new SkyThemeSettings( - SkyTheme.presets.default, - SkyThemeMode.presets.light, - ), - previousSettings: undefined, - }), - }; - TestBed.configureTestingModule({ - imports: [SkyDropdownFixturesModule, SkyThemeModule], - providers: [ - { - provide: SkyThemeService, - useValue: mockThemeService, - }, - { - provide: SKY_STACKING_CONTEXT, - useValue: { - zIndex: new BehaviorSubject(111), - }, - }, - SkyContentInfoProvider, - ], - }); + function isMenuItemFocused(index: number): boolean { + const menuItemButtons = document.querySelectorAll( + '.sky-dropdown-item button', + ); + return isElementFocused(menuItemButtons[index]); + } - fixture = TestBed.createComponent(DropdownFixtureComponent); + function isElementFocused(elem: Element | undefined | null): boolean { + return !!elem && elem === document.activeElement; + } - contentInfoProvider = TestBed.inject(SkyContentInfoProvider); + function isElementVisible(elem: Element | undefined | null): boolean { + return !!elem && getComputedStyle(elem).visibility !== 'hidden'; + } - idService = TestBed.inject(SkyIdService); - }); + /** + * Multiple ticks are needed to accommodate setTimeout and observable streams. + */ + function detectChangesFakeAsync(): void { + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + tick(); + } - afterEach(() => { - ( - TestBed.inject(SKY_STACKING_CONTEXT).zIndex as BehaviorSubject - ).complete(); - }); + //#endregion + + beforeEach(() => { + mockThemeService = { + settingsChange: new BehaviorSubject({ + currentSettings: new SkyThemeSettings( + SkyTheme.presets.default, + SkyThemeMode.presets.light, + ), + previousSettings: undefined, + }), + }; + TestBed.configureTestingModule({ + imports: [SkyDropdownFixturesModule, SkyThemeModule], + providers: [ + { + provide: SkyThemeService, + useValue: mockThemeService, + }, + { + provide: SKY_STACKING_CONTEXT, + useValue: { + zIndex: new BehaviorSubject(111), + }, + }, + SkyContentInfoProvider, + ], + }); - it('should set defaults', fakeAsync(() => { - detectChangesFakeAsync(); + fixture = TestBed.createComponent(DropdownFixtureComponent); + fixture.componentInstance.useCustomTrigger = useCustomTrigger; - const dropdownRef = fixture.componentInstance.dropdownRef; - expect(dropdownRef?.buttonStyle).toEqual('default'); - expect(dropdownRef?.buttonType).toEqual('select'); - expect(dropdownRef?.disabled).toBeUndefined(); - expect(dropdownRef?.horizontalAlignment).toEqual('left'); - expect(dropdownRef?.label).toBeUndefined(); - expect(dropdownRef?.title).toBeUndefined(); - expect(dropdownRef?.trigger).toEqual('click'); - - const menuRef = fixture.componentInstance.dropdownMenuRef; - expect(menuRef?.ariaLabelledBy).toBeUndefined(); - expect(menuRef?.ariaRole).toEqual('menu'); - expect(menuRef?.useNativeFocus).toEqual(true); - - const itemRefs = fixture.componentInstance.dropdownItemRefs; - expect(itemRefs?.first.ariaRole).toEqual('menuitem'); - - const button = getButtonElement(); - expect(button).toHaveCssClass('sky-btn-default'); - })); + contentInfoProvider = TestBed.inject(SkyContentInfoProvider); - it('should change theme', fakeAsync(() => { - detectChangesFakeAsync(); - mockThemeService.settingsChange.next({ - currentSettings: new SkyThemeSettings( - SkyTheme.presets.modern, - SkyThemeMode.presets.light, - ), - previousSettings: - mockThemeService.settingsChange.getValue().currentSettings, + idService = TestBed.inject(SkyIdService); }); - detectChangesFakeAsync(); - const icon = fixture.nativeElement.querySelector('.sky-i-chevron-down'); - expect(icon).toExist(); - })); - it('should shut down cleanly', fakeAsync(() => { - detectChangesFakeAsync(); - const button = getButtonElement(); - // Open the menu. - button?.click(); - detectChangesFakeAsync(); - expect(fixture.componentInstance.dropdownRef?.isOpen).toBeTrue(); - fixture.componentInstance.show = false; - detectChangesFakeAsync(); - tick(); - expect(fixture.componentInstance.dropdownRef).toBeFalsy(); - })); + afterEach(() => { + ( + TestBed.inject(SKY_STACKING_CONTEXT).zIndex as BehaviorSubject + ).complete(); + }); - it('should allow setting the horizontal alignment', fakeAsync( - inject([SkyAffixService], (affixService: SkyAffixService) => { - const expectedAlignment = 'center'; + it('should set defaults', fakeAsync(() => { + detectChangesFakeAsync(); - fixture.componentInstance.horizontalAlignment = expectedAlignment; + const dropdownRef = fixture.componentInstance.dropdownRef; + expect(dropdownRef?.buttonStyle).toEqual('default'); + expect(dropdownRef?.buttonType).toEqual('select'); + expect(dropdownRef?.disabled).toBeUndefined(); + expect(dropdownRef?.horizontalAlignment).toEqual('left'); + expect(dropdownRef?.label).toBeUndefined(); + expect(dropdownRef?.title).toBeUndefined(); + expect(dropdownRef?.trigger).toEqual('click'); - let actualConfig: SkyAffixConfig | undefined; + const menuRef = fixture.componentInstance.dropdownMenuRef; + expect(menuRef?.ariaLabelledBy).toBeUndefined(); + expect(menuRef?.ariaRole).toEqual('menu'); + expect(menuRef?.useNativeFocus).toEqual(true); - const mockAffixer: any = { - placementChange: observableOf({}), - affixTo(elem: any, config: SkyAffixConfig) { - actualConfig = config; - }, - destroy() {}, - reaffix() {}, - }; + const itemRefs = fixture.componentInstance.dropdownItemRefs; + expect(itemRefs?.first.ariaRole).toEqual('menuitem'); - detectChangesFakeAsync(); const button = getButtonElement(); - const createAffixerSpy = spyOn( - affixService, - 'createAffixer', - ).and.returnValue(mockAffixer); + if (!useCustomTrigger) { + expect(button).toHaveCssClass('sky-btn-default'); + } + })); + + it('should shut down cleanly', fakeAsync(() => { detectChangesFakeAsync(); + const button = getButtonElement(); + // Open the menu. button?.click(); detectChangesFakeAsync(); + expect(fixture.componentInstance.dropdownRef?.isOpen).toBeTrue(); + fixture.componentInstance.show = false; + detectChangesFakeAsync(); + tick(); + expect(fixture.componentInstance.dropdownRef).toBeFalsy(); + })); - expect(actualConfig?.horizontalAlignment).toEqual(expectedAlignment); - - // Clear the spy to return the service to normal. - createAffixerSpy.and.callThrough(); - }), - )); - - it('should allow setting button style and type', fakeAsync(() => { - fixture.componentInstance.buttonStyle = 'danger'; - fixture.componentInstance.buttonType = 'context-menu'; - - detectChangesFakeAsync(); - - const button = getButtonElement(); - expect(button).toHaveCssClass('sky-btn-danger'); - expect(button).toHaveCssClass('sky-dropdown-button-type-context-menu'); - })); - - it('should reposition the menu when number of menu items change', fakeAsync(() => { - detectChangesFakeAsync(); - - const button = getButtonElement(); - - button?.click(); - detectChangesFakeAsync(); - - expect(fixture.componentInstance.dropdownItemRefs?.length).toEqual(4); - - const spy = spyOn( - fixture.componentInstance.messageStream, - 'next', - ).and.callThrough(); - fixture.componentInstance.changeItems(); - - detectChangesFakeAsync(); - - expect(fixture.componentInstance.dropdownItemRefs?.length).toEqual(3); - expect(spy).toHaveBeenCalledWith({ - type: SkyDropdownMessageType.Reposition, - }); - })); - - it('should add scrollbars for long list of dropdown items', fakeAsync(() => { - detectChangesFakeAsync(); - - const button = getButtonElement(); - - button?.click(); - detectChangesFakeAsync(); - - const menu = getMenuElement(); - - // Should NOT have a scrollbar. - expect(menu && menu.scrollHeight > menu.clientHeight).toEqual(false); - - fixture.componentInstance.setManyItems(); - detectChangesFakeAsync(); + it('should allow setting the horizontal alignment', fakeAsync( + inject([SkyAffixService], (affixService: SkyAffixService) => { + const expectedAlignment = 'center'; - // Should now have a scrollbar. - expect(menu && menu.scrollHeight > menu.clientHeight).toEqual(true); - })); + fixture.componentInstance.horizontalAlignment = expectedAlignment; - it('should emit when a menu item is clicked', fakeAsync(() => { - const menuChangesSpy = spyOn( - fixture.componentInstance, - 'onMenuChanges', - ).and.callThrough(); - const itemClickSpy = spyOn( - fixture.componentInstance, - 'onItemClick', - ).and.callThrough(); - detectChangesFakeAsync(); + let actualConfig: SkyAffixConfig | undefined; - const button = getButtonElement(); + const mockAffixer: any = { + placementChange: observableOf({}), + affixTo(elem: any, config: SkyAffixConfig) { + actualConfig = config; + }, + destroy() {}, + reaffix() {}, + }; - // Open the menu. - button?.click(); - detectChangesFakeAsync(); + detectChangesFakeAsync(); + const button = getButtonElement(); + const createAffixerSpy = spyOn( + affixService, + 'createAffixer', + ).and.returnValue(mockAffixer); - // Click third item button. - const buttonIndex = 2; - const firstItemButton = getMenuItems() - ?.item(buttonIndex) - .querySelector('button'); - firstItemButton?.click(); - detectChangesFakeAsync(); + detectChangesFakeAsync(); + button?.click(); + detectChangesFakeAsync(); - const selectedItem = fixture.componentInstance.dropdownItemRefs?.find( - (item, i) => { - return i === buttonIndex; - }, - ); - - expect(menuChangesSpy).toHaveBeenCalledWith({ activeIndex: buttonIndex }); - expect(menuChangesSpy).toHaveBeenCalledWith({ selectedItem }); - expect(itemClickSpy).toHaveBeenCalledWith( - fixture.componentInstance.items[buttonIndex].name, - ); - })); + expect(actualConfig?.horizontalAlignment).toEqual(expectedAlignment); - it('should survive reposition when items change as the menu opens', fakeAsync(() => { - detectChangesFakeAsync(); - if (fixture.componentInstance.dropdownRef) { - fixture.componentInstance.dropdownRef.isOpen = true; - } - fixture.componentInstance.changeItems(); - detectChangesFakeAsync(); - - expect(fixture.componentInstance.dropdownItemRefs?.length).toEqual(3); - })); + // Clear the spy to return the service to normal. + createAffixerSpy.and.callThrough(); + }), + )); - describe('mouse interactions', function () { - it('should open and close menu via mouse click', fakeAsync(() => { - fixture.componentInstance.trigger = 'click'; + it('should reposition the menu when number of menu items change', fakeAsync(() => { detectChangesFakeAsync(); const button = getButtonElement(); button?.click(); - // Simulate mouse movement as well. - SkyAppTestUtility.fireDomEvent(button, 'mouseenter'); detectChangesFakeAsync(); - let dropdownMenu = getMenuElement(); - expect(isElementVisible(dropdownMenu)).toEqual(true); + expect(fixture.componentInstance.dropdownItemRefs?.length).toEqual(4); - button?.click(); - // Simulate mouse movement as well. - SkyAppTestUtility.fireDomEvent(button, 'mouseleave'); - detectChangesFakeAsync(); + const spy = spyOn( + fixture.componentInstance.messageStream, + 'next', + ).and.callThrough(); + fixture.componentInstance.changeItems(); - dropdownMenu = getMenuElement(); + detectChangesFakeAsync(); - expect(dropdownMenu).toBeNull(); + expect(fixture.componentInstance.dropdownItemRefs?.length).toEqual(3); + expect(spy).toHaveBeenCalledWith({ + type: SkyDropdownMessageType.Reposition, + }); })); - it('should open and close menu via mouse hover', fakeAsync(() => { - fixture.componentInstance.trigger = 'hover'; + it('should add scrollbars for long list of dropdown items', fakeAsync(() => { detectChangesFakeAsync(); const button = getButtonElement(); - SkyAppTestUtility.fireDomEvent(button, 'mouseenter'); + button?.click(); detectChangesFakeAsync(); - let container = getMenuContainerElement(); - let menu = getMenuElement(); + const menu = getMenuElement(); - expect(isElementVisible(container)).toEqual(true); + // Should NOT have a scrollbar. + expect(menu && menu.scrollHeight > menu.clientHeight).toEqual(false); - // Simulate moving the mouse to the menu. - SkyAppTestUtility.fireDomEvent(button, 'mouseleave'); - SkyAppTestUtility.fireDomEvent(menu, 'mouseenter'); + fixture.componentInstance.setManyItems(); detectChangesFakeAsync(); - container = getMenuContainerElement(); - menu = getMenuElement(); - - // Confirm menu is still open. - expect(isElementVisible(container)).toEqual(true); + // Should now have a scrollbar. + expect(menu && menu.scrollHeight > menu.clientHeight).toEqual(true); + })); - // Simulate moving the mouse from the menu to the trigger button. - SkyAppTestUtility.fireDomEvent(menu, 'mouseleave'); - SkyAppTestUtility.fireDomEvent(button, 'mouseenter'); + it('should emit when a menu item is clicked', fakeAsync(() => { + const menuChangesSpy = spyOn( + fixture.componentInstance, + 'onMenuChanges', + ).and.callThrough(); + const itemClickSpy = spyOn( + fixture.componentInstance, + 'onItemClick', + ).and.callThrough(); detectChangesFakeAsync(); - container = getMenuContainerElement(); - menu = getMenuElement(); - - // Confirm menu is still open. - expect(isElementVisible(container)).toEqual(true); + const button = getButtonElement(); - // Simulate mouse leaving the trigger button. - SkyAppTestUtility.fireDomEvent(button, 'mouseleave'); + // Open the menu. + button?.click(); detectChangesFakeAsync(); - container = getMenuContainerElement(); - - // Menu should now be closed. - expect(container).toBeNull(); - - // Re-open the menu. - SkyAppTestUtility.fireDomEvent(button, 'mouseenter'); + // Click third item button. + const buttonIndex = 2; + const firstItemButton = getMenuItems() + ?.item(buttonIndex) + .querySelector('button'); + firstItemButton?.click(); detectChangesFakeAsync(); - container = getMenuContainerElement(); - menu = getMenuElement(); + const selectedItem = fixture.componentInstance.dropdownItemRefs?.find( + (item, i) => { + return i === buttonIndex; + }, + ); - expect(isElementVisible(container)).toEqual(true); + expect(menuChangesSpy).toHaveBeenCalledWith({ activeIndex: buttonIndex }); + expect(menuChangesSpy).toHaveBeenCalledWith({ selectedItem }); + expect(itemClickSpy).toHaveBeenCalledWith( + fixture.componentInstance.items[buttonIndex].name, + ); + })); - // Simulate moving the mouse to the menu. - SkyAppTestUtility.fireDomEvent(button, 'mouseleave'); - SkyAppTestUtility.fireDomEvent(menu, 'mouseenter'); + it('should survive reposition when items change as the menu opens', fakeAsync(() => { + detectChangesFakeAsync(); + if (fixture.componentInstance.dropdownRef) { + fixture.componentInstance.dropdownRef.isOpen = true; + } + fixture.componentInstance.changeItems(); detectChangesFakeAsync(); - container = getMenuContainerElement(); - menu = getMenuElement(); + expect(fixture.componentInstance.dropdownItemRefs?.length).toEqual(3); + })); - // Confirm menu is still open. - expect(isElementVisible(container)).toEqual(true); + describe('mouse interactions', function () { + it('should open and close menu via mouse click', fakeAsync(() => { + fixture.componentInstance.trigger = 'click'; + detectChangesFakeAsync(); - // Simulate mouse leaving the menu completely. - SkyAppTestUtility.fireDomEvent(menu, 'mouseleave'); - detectChangesFakeAsync(); + const button = getButtonElement(); - container = getMenuContainerElement(); + button?.click(); + // Simulate mouse movement as well. + SkyAppTestUtility.fireDomEvent(button, 'mouseenter'); + detectChangesFakeAsync(); - // Menu should now be closed. - expect(container).toBeNull(); - })); + let dropdownMenu = getMenuElement(); + expect(isElementVisible(dropdownMenu)).toEqual(true); - it('should close menu when clicking outside', fakeAsync(() => { - detectChangesFakeAsync(); + button?.click(); + // Simulate mouse movement as well. + SkyAppTestUtility.fireDomEvent(button, 'mouseleave'); + detectChangesFakeAsync(); - const button = getButtonElement(); - button?.click(); - detectChangesFakeAsync(); + dropdownMenu = getMenuElement(); - let container = getMenuContainerElement(); + expect(dropdownMenu).toBeNull(); + })); - expect(isElementVisible(container)).toEqual(true); + it('should open and close menu via mouse hover', fakeAsync(() => { + fixture.componentInstance.trigger = 'hover'; + detectChangesFakeAsync(); - SkyAppTestUtility.fireDomEvent(window.document.body, 'click'); - detectChangesFakeAsync(); + const button = getButtonElement(); - container = getMenuContainerElement(); + SkyAppTestUtility.fireDomEvent(button, 'mouseenter'); + detectChangesFakeAsync(); - expect(container).toBeNull(); - })); + let container = getMenuContainerElement(); + let menu = getMenuElement(); - it('should focus on first menu item when clicked', fakeAsync(() => { - detectChangesFakeAsync(); - const spy = spyOn( - fixture.componentInstance.messageStream, - 'next', - ).and.callThrough(); - const button = getButtonElement(); - button?.click(); - detectChangesFakeAsync(); + expect(isElementVisible(container)).toEqual(true); - expect(spy).toHaveBeenCalledWith({ type: SkyDropdownMessageType.Open }); - expect(spy).toHaveBeenCalledWith({ - type: SkyDropdownMessageType.FocusFirstItem, - }); - })); - }); + // Simulate moving the mouse to the menu. + SkyAppTestUtility.fireDomEvent(button, 'mouseleave'); + SkyAppTestUtility.fireDomEvent(menu, 'mouseenter'); + detectChangesFakeAsync(); - describe('keyboard interactions', function () { - it('should open menu and focus first item with arrowdown key', fakeAsync(() => { - detectChangesFakeAsync(); + container = getMenuContainerElement(); + menu = getMenuElement(); - const button = getButtonElement(); + // Confirm menu is still open. + expect(isElementVisible(container)).toEqual(true); - SkyAppTestUtility.fireDomEvent(button, 'keydown', { - keyboardEventInit: { - key: 'arrowdown', - }, - }); + // Simulate moving the mouse from the menu to the trigger button. + SkyAppTestUtility.fireDomEvent(menu, 'mouseleave'); + SkyAppTestUtility.fireDomEvent(button, 'mouseenter'); + detectChangesFakeAsync(); - detectChangesFakeAsync(); + container = getMenuContainerElement(); + menu = getMenuElement(); - let container = getMenuContainerElement(); + // Confirm menu is still open. + expect(isElementVisible(container)).toEqual(true); - expect(isElementVisible(container)).toEqual(true); + // Simulate mouse leaving the trigger button. + SkyAppTestUtility.fireDomEvent(button, 'mouseleave'); + detectChangesFakeAsync(); - // Close the dropdown. - button?.click(); - detectChangesFakeAsync(); + container = getMenuContainerElement(); - container = getMenuContainerElement(); + // Menu should now be closed. + expect(container).toBeNull(); - expect(container).toBeNull(); + // Re-open the menu. + SkyAppTestUtility.fireDomEvent(button, 'mouseenter'); + detectChangesFakeAsync(); - // IE 11 uses 'down'. - SkyAppTestUtility.fireDomEvent(button, 'keydown', { - keyboardEventInit: { - key: 'down', - }, - }); + container = getMenuContainerElement(); + menu = getMenuElement(); - detectChangesFakeAsync(); + expect(isElementVisible(container)).toEqual(true); - container = getMenuContainerElement(); - const menuItems = getMenuItems(); - const firstButton = menuItems?.item(0)?.querySelector('button'); + // Simulate moving the mouse to the menu. + SkyAppTestUtility.fireDomEvent(button, 'mouseleave'); + SkyAppTestUtility.fireDomEvent(menu, 'mouseenter'); + detectChangesFakeAsync(); - expect(isElementVisible(container)).toEqual(true); - expect(isElementFocused(firstButton)).toEqual(true); - })); + container = getMenuContainerElement(); + menu = getMenuElement(); - it('should open menu and focus last item with arrowup key', fakeAsync(() => { - detectChangesFakeAsync(); + // Confirm menu is still open. + expect(isElementVisible(container)).toEqual(true); - const button = getButtonElement(); + // Simulate mouse leaving the menu completely. + SkyAppTestUtility.fireDomEvent(menu, 'mouseleave'); + detectChangesFakeAsync(); - SkyAppTestUtility.fireDomEvent(button, 'keydown', { - keyboardEventInit: { - key: 'arrowup', - }, - }); + container = getMenuContainerElement(); - detectChangesFakeAsync(); + // Menu should now be closed. + expect(container).toBeNull(); + })); - let container = getMenuContainerElement(); + it('should close menu when clicking outside', fakeAsync(() => { + detectChangesFakeAsync(); - expect(isElementVisible(container)).toEqual(true); + const button = getButtonElement(); + button?.click(); + detectChangesFakeAsync(); - // Close the dropdown. - button?.click(); - detectChangesFakeAsync(); + let container = getMenuContainerElement(); - container = getMenuContainerElement(); + expect(isElementVisible(container)).toEqual(true); - expect(container).toBeNull(); + SkyAppTestUtility.fireDomEvent(window.document.body, 'click'); + detectChangesFakeAsync(); - // IE 11 uses 'up'. - SkyAppTestUtility.fireDomEvent(button, 'keydown', { - keyboardEventInit: { - key: 'up', - }, - }); + container = getMenuContainerElement(); - detectChangesFakeAsync(); + expect(container).toBeNull(); + })); - container = getMenuContainerElement(); - const menuItems = getMenuItems(); - const lastButton = menuItems - ?.item(menuItems.length - 1) - ?.querySelector('button'); + it('should focus on first menu item when clicked', fakeAsync(() => { + detectChangesFakeAsync(); + const spy = spyOn( + fixture.componentInstance.messageStream, + 'next', + ).and.callThrough(); + const button = getButtonElement(); + button?.click(); + detectChangesFakeAsync(); - expect(isElementVisible(container)).toEqual(true); - expect(isElementFocused(lastButton)).toEqual(true); - })); + expect(spy).toHaveBeenCalledWith({ type: SkyDropdownMessageType.Open }); + expect(spy).toHaveBeenCalledWith({ + type: SkyDropdownMessageType.FocusFirstItem, + }); + })); + }); - it('should not focus last item if it is disabled', fakeAsync(() => { - fixture.componentInstance.items[ - fixture.componentInstance.items.length - 1 - ].disabled = true; - detectChangesFakeAsync(); + describe('keyboard interactions', function () { + it('should open menu and focus first item with arrowdown key', fakeAsync(() => { + detectChangesFakeAsync(); - const button = getButtonElement(); + const button = getButtonElement(); - SkyAppTestUtility.fireDomEvent(button, 'keydown', { - keyboardEventInit: { - key: 'arrowup', - }, - }); + SkyAppTestUtility.fireDomEvent(button, 'keydown', { + keyboardEventInit: { + key: 'arrowdown', + }, + }); - detectChangesFakeAsync(); + detectChangesFakeAsync(); - const container = getMenuContainerElement(); - const menuItems = getMenuItems(); - expect(isElementVisible(container)).toEqual(true); - expect(menuItems && isMenuItemFocused(menuItems.length - 1)).toEqual( - false, - ); - expect(menuItems && isMenuItemFocused(menuItems.length - 2)).toEqual( - true, - ); - })); + let container = getMenuContainerElement(); - it('should close menu with escape key while trigger button is focused', fakeAsync(() => { - detectChangesFakeAsync(); + expect(isElementVisible(container)).toEqual(true); - const button = getButtonElement(); - button?.click(); - detectChangesFakeAsync(); + // Close the dropdown. + button?.click(); + detectChangesFakeAsync(); - let container = getMenuContainerElement(); + container = getMenuContainerElement(); - expect(isElementVisible(container)).toEqual(true); + expect(container).toBeNull(); - SkyAppTestUtility.fireDomEvent(button, 'keydown', { - keyboardEventInit: { - key: 'escape', - }, - }); + // IE 11 uses 'down'. + SkyAppTestUtility.fireDomEvent(button, 'keydown', { + keyboardEventInit: { + key: 'down', + }, + }); - detectChangesFakeAsync(); + detectChangesFakeAsync(); - container = getMenuContainerElement(); + container = getMenuContainerElement(); + const menuItems = getMenuItems(); + const firstButton = menuItems?.item(0)?.querySelector('button'); - expect(container).toBeNull(); - })); + expect(isElementVisible(container)).toEqual(true); + expect(isElementFocused(firstButton)).toEqual(true); + })); - it('should close menu with escape key while menu is focused', fakeAsync(() => { - detectChangesFakeAsync(); + it('should open menu and focus last item with arrowup key', fakeAsync(() => { + detectChangesFakeAsync(); - const button = getButtonElement(); - button?.click(); - detectChangesFakeAsync(); + const button = getButtonElement(); - let container = getMenuContainerElement(); - const firstItem = getFirstMenuItem(); + SkyAppTestUtility.fireDomEvent(button, 'keydown', { + keyboardEventInit: { + key: 'arrowup', + }, + }); - expect(isElementVisible(container)).toEqual(true); + detectChangesFakeAsync(); - SkyAppTestUtility.fireDomEvent(firstItem, 'keydown', { - keyboardEventInit: { - key: 'escape', - }, - }); + let container = getMenuContainerElement(); - detectChangesFakeAsync(); + expect(isElementVisible(container)).toEqual(true); - container = getMenuContainerElement(); + // Close the dropdown. + button?.click(); + detectChangesFakeAsync(); - expect(container).toBeNull(); - expect(isElementFocused(button)).toEqual(true); - })); + container = getMenuContainerElement(); - it('should focus first item if opened with enter key', fakeAsync(() => { - detectChangesFakeAsync(); + expect(container).toBeNull(); - const button = getButtonElement(); + // IE 11 uses 'up'. + SkyAppTestUtility.fireDomEvent(button, 'keydown', { + keyboardEventInit: { + key: 'up', + }, + }); - SkyAppTestUtility.fireDomEvent(button, 'keydown', { - keyboardEventInit: { - key: 'enter', - }, - }); + detectChangesFakeAsync(); - detectChangesFakeAsync(); + container = getMenuContainerElement(); + const menuItems = getMenuItems(); + const lastButton = menuItems + ?.item(menuItems.length - 1) + ?.querySelector('button'); - const container = getMenuContainerElement(); + expect(isElementVisible(container)).toEqual(true); + expect(isElementFocused(lastButton)).toEqual(true); + })); - expect(isElementVisible(container)).toEqual(true); - expect(isMenuItemFocused(0)).toEqual(true); - })); + it('should not focus last item if it is disabled', fakeAsync(() => { + fixture.componentInstance.items[ + fixture.componentInstance.items.length - 1 + ].disabled = true; + detectChangesFakeAsync(); - it('should allow disabling native focus', fakeAsync(() => { - fixture.componentInstance.useNativeFocus = false; - detectChangesFakeAsync(); + const button = getButtonElement(); - const button = getButtonElement(); + SkyAppTestUtility.fireDomEvent(button, 'keydown', { + keyboardEventInit: { + key: 'arrowup', + }, + }); - SkyAppTestUtility.fireDomEvent(button, 'keydown', { - keyboardEventInit: { - key: 'enter', - }, - }); + detectChangesFakeAsync(); - detectChangesFakeAsync(); + const container = getMenuContainerElement(); + const menuItems = getMenuItems(); + expect(isElementVisible(container)).toEqual(true); + expect(menuItems && isMenuItemFocused(menuItems.length - 1)).toEqual( + false, + ); + expect(menuItems && isMenuItemFocused(menuItems.length - 2)).toEqual( + true, + ); + })); - const container = getMenuContainerElement(); + it('should close menu with escape key while trigger button is focused', fakeAsync(() => { + detectChangesFakeAsync(); - // The menu should be open, but the first item should not be focused. - expect(isElementVisible(container)).toEqual(true); - expect(isMenuItemFocused(0)).toEqual(false); - })); + const button = getButtonElement(); + button?.click(); + detectChangesFakeAsync(); - it('should not focus the first item if it is disabled', fakeAsync(() => { - fixture.componentInstance.items[0].disabled = true; - detectChangesFakeAsync(); + let container = getMenuContainerElement(); - const button = getButtonElement(); + expect(isElementVisible(container)).toEqual(true); - SkyAppTestUtility.fireDomEvent(button, 'keydown', { - keyboardEventInit: { - key: 'enter', - }, - }); + SkyAppTestUtility.fireDomEvent(button, 'keydown', { + keyboardEventInit: { + key: 'escape', + }, + }); - detectChangesFakeAsync(); + detectChangesFakeAsync(); - const container = getMenuContainerElement(); + container = getMenuContainerElement(); - expect(isElementVisible(container)).toEqual(true); - expect(isMenuItemFocused(0)).toEqual(false); - expect(isMenuItemFocused(2)).toEqual(true); - })); + expect(container).toBeNull(); + })); - it('should handle all items being disabled', fakeAsync(() => { - fixture.componentInstance.items = [ - { - name: 'Option 1', - disabled: true, - }, - { - name: 'Option 2', - disabled: true, - }, - ]; - detectChangesFakeAsync(); + it('should close menu with escape key while menu is focused', fakeAsync(() => { + detectChangesFakeAsync(); - const button = getButtonElement(); + const button = getButtonElement(); + button?.click(); + detectChangesFakeAsync(); - SkyAppTestUtility.fireDomEvent(button, 'keydown', { - keyboardEventInit: { - key: 'enter', - }, - }); + let container = getMenuContainerElement(); + const firstItem = getFirstMenuItem(); - detectChangesFakeAsync(); + expect(isElementVisible(container)).toEqual(true); - expect(isMenuItemFocused(0)).toEqual(false); - expect(isMenuItemFocused(1)).toEqual(false); + SkyAppTestUtility.fireDomEvent(firstItem, 'keydown', { + keyboardEventInit: { + key: 'escape', + }, + }); - const menu = getMenuElement(); + detectChangesFakeAsync(); - // Attempt to move to next item. - SkyAppTestUtility.fireDomEvent(menu, 'keydown', { - keyboardEventInit: { - key: 'arrowdown', - }, - }); + container = getMenuContainerElement(); - detectChangesFakeAsync(); + expect(container).toBeNull(); + expect(isElementFocused(button)).toEqual(true); + })); - expect(isMenuItemFocused(0)).toEqual(false); - expect(isMenuItemFocused(1)).toEqual(false); + it('should focus first item if opened with enter key', fakeAsync(() => { + detectChangesFakeAsync(); - // Attempt to move to previous item. - SkyAppTestUtility.fireDomEvent(menu, 'keydown', { - keyboardEventInit: { - key: 'arrowup', - }, - }); + const button = getButtonElement(); - detectChangesFakeAsync(); + SkyAppTestUtility.fireDomEvent(button, 'keydown', { + keyboardEventInit: { + key: 'enter', + }, + }); - expect(isMenuItemFocused(0)).toEqual(false); - expect(isMenuItemFocused(1)).toEqual(false); + detectChangesFakeAsync(); - // Try to focus the last item using the up arrow. - SkyAppTestUtility.fireDomEvent(button, 'keydown', { - keyboardEventInit: { - key: 'up', - }, - }); + const container = getMenuContainerElement(); - detectChangesFakeAsync(); + expect(isElementVisible(container)).toEqual(true); + expect(isMenuItemFocused(0)).toEqual(true); + })); - expect(isMenuItemFocused(0)).toEqual(false); - expect(isMenuItemFocused(1)).toEqual(false); - })); + it('should allow disabling native focus', fakeAsync(() => { + fixture.componentInstance.useNativeFocus = false; + detectChangesFakeAsync(); - it('should navigate menu with arrow keys', fakeAsync(() => { - detectChangesFakeAsync(); + const button = getButtonElement(); - const button = getButtonElement(); + SkyAppTestUtility.fireDomEvent(button, 'keydown', { + keyboardEventInit: { + key: 'enter', + }, + }); - SkyAppTestUtility.fireDomEvent(button, 'keydown', { - keyboardEventInit: { - key: 'enter', - }, - }); + detectChangesFakeAsync(); - detectChangesFakeAsync(); + const container = getMenuContainerElement(); - expect(isMenuItemFocused(0)).toEqual(true); + // The menu should be open, but the first item should not be focused. + expect(isElementVisible(container)).toEqual(true); + expect(isMenuItemFocused(0)).toEqual(false); + })); - const menu = getMenuElement(); + it('should not focus the first item if it is disabled', fakeAsync(() => { + fixture.componentInstance.items[0].disabled = true; + detectChangesFakeAsync(); - SkyAppTestUtility.fireDomEvent(menu, 'keydown', { - keyboardEventInit: { - key: 'arrowdown', - }, - }); + const button = getButtonElement(); - detectChangesFakeAsync(); + SkyAppTestUtility.fireDomEvent(button, 'keydown', { + keyboardEventInit: { + key: 'enter', + }, + }); - // Should skip second item because it is disabled. - expect(isMenuItemFocused(2)).toEqual(true); + detectChangesFakeAsync(); - // Try IE 11 'down' key. - SkyAppTestUtility.fireDomEvent(menu, 'keydown', { - keyboardEventInit: { - key: 'down', - }, - }); + const container = getMenuContainerElement(); - detectChangesFakeAsync(); + expect(isElementVisible(container)).toEqual(true); + expect(isMenuItemFocused(0)).toEqual(false); + expect(isMenuItemFocused(2)).toEqual(true); + })); - expect(isMenuItemFocused(3)).toEqual(true); + it('should handle all items being disabled', fakeAsync(() => { + fixture.componentInstance.items = [ + { + name: 'Option 1', + disabled: true, + }, + { + name: 'Option 2', + disabled: true, + }, + ]; + detectChangesFakeAsync(); - SkyAppTestUtility.fireDomEvent(menu, 'keydown', { - keyboardEventInit: { - key: 'arrowdown', - }, - }); + const button = getButtonElement(); - detectChangesFakeAsync(); + SkyAppTestUtility.fireDomEvent(button, 'keydown', { + keyboardEventInit: { + key: 'enter', + }, + }); - // It should loop back to first item. - expect(isMenuItemFocused(0)).toEqual(true); + detectChangesFakeAsync(); - SkyAppTestUtility.fireDomEvent(menu, 'keydown', { - keyboardEventInit: { - key: 'arrowup', - }, - }); + expect(isMenuItemFocused(0)).toEqual(false); + expect(isMenuItemFocused(1)).toEqual(false); - detectChangesFakeAsync(); + const menu = getMenuElement(); - // It should loop back to last item. - expect(isMenuItemFocused(3)).toEqual(true); + // Attempt to move to next item. + SkyAppTestUtility.fireDomEvent(menu, 'keydown', { + keyboardEventInit: { + key: 'arrowdown', + }, + }); - // Try IE 11's 'up' key. - SkyAppTestUtility.fireDomEvent(menu, 'keydown', { - keyboardEventInit: { - key: 'up', - }, - }); + detectChangesFakeAsync(); - detectChangesFakeAsync(); + expect(isMenuItemFocused(0)).toEqual(false); + expect(isMenuItemFocused(1)).toEqual(false); - expect(isMenuItemFocused(2)).toEqual(true); - })); + // Attempt to move to previous item. + SkyAppTestUtility.fireDomEvent(menu, 'keydown', { + keyboardEventInit: { + key: 'arrowup', + }, + }); - it('should close the menu after trigger button loses focus', fakeAsync(() => { - detectChangesFakeAsync(); + detectChangesFakeAsync(); - const button = getButtonElement(); + expect(isMenuItemFocused(0)).toEqual(false); + expect(isMenuItemFocused(1)).toEqual(false); - SkyAppTestUtility.fireDomEvent(button, 'keydown', { - keyboardEventInit: { - key: 'enter', - }, - }); + // Try to focus the last item using the up arrow. + SkyAppTestUtility.fireDomEvent(button, 'keydown', { + keyboardEventInit: { + key: 'up', + }, + }); - detectChangesFakeAsync(); + detectChangesFakeAsync(); - let container = getMenuContainerElement(); + expect(isMenuItemFocused(0)).toEqual(false); + expect(isMenuItemFocused(1)).toEqual(false); + })); - expect(isElementVisible(container)).toEqual(true); + it('should navigate menu with arrow keys', fakeAsync(() => { + detectChangesFakeAsync(); - // Run 'tab' on trigger button. - SkyAppTestUtility.fireDomEvent(button, 'keydown', { - keyboardEventInit: { - key: 'tab', - }, - }); - button?.blur(); + const button = getButtonElement(); - detectChangesFakeAsync(); + SkyAppTestUtility.fireDomEvent(button, 'keydown', { + keyboardEventInit: { + key: 'enter', + }, + }); - container = getMenuContainerElement(); + detectChangesFakeAsync(); - // Tab key should progress to next item after the trigger button. - expect(container).toBeNull(); - })); + expect(isMenuItemFocused(0)).toEqual(true); - it('should close the menu when tab key is pressed within the menu', fakeAsync(() => { - detectChangesFakeAsync(); + const menu = getMenuElement(); - const button = getButtonElement(); + SkyAppTestUtility.fireDomEvent(menu, 'keydown', { + keyboardEventInit: { + key: 'arrowdown', + }, + }); - SkyAppTestUtility.fireDomEvent(button, 'keydown', { - keyboardEventInit: { - key: 'down', - }, - }); + detectChangesFakeAsync(); - detectChangesFakeAsync(); + // Should skip second item because it is disabled. + expect(isMenuItemFocused(2)).toEqual(true); - let container = getMenuContainerElement(); + // Try IE 11 'down' key. + SkyAppTestUtility.fireDomEvent(menu, 'keydown', { + keyboardEventInit: { + key: 'down', + }, + }); - expect(isElementVisible(container)).toEqual(true); + detectChangesFakeAsync(); - const menuItems = getMenuItems(); + expect(isMenuItemFocused(3)).toEqual(true); - SkyAppTestUtility.fireDomEvent(menuItems?.item(0), 'keydown', { - keyboardEventInit: { - key: 'tab', - }, - }); + SkyAppTestUtility.fireDomEvent(menu, 'keydown', { + keyboardEventInit: { + key: 'arrowdown', + }, + }); - detectChangesFakeAsync(); + detectChangesFakeAsync(); - container = getMenuContainerElement(); + // It should loop back to first item. + expect(isMenuItemFocused(0)).toEqual(true); - expect(container).toBeNull(); - })); - }); + SkyAppTestUtility.fireDomEvent(menu, 'keydown', { + keyboardEventInit: { + key: 'arrowup', + }, + }); - describe('message stream', function () { - it('should open and close the menu', fakeAsync(() => { - detectChangesFakeAsync(); + detectChangesFakeAsync(); - let container = getMenuContainerElement(); + // It should loop back to last item. + expect(isMenuItemFocused(3)).toEqual(true); - // Verify the menu is closed on startup. - expect(container).toBeNull(); + // Try IE 11's 'up' key. + SkyAppTestUtility.fireDomEvent(menu, 'keydown', { + keyboardEventInit: { + key: 'up', + }, + }); - fixture.componentInstance.sendMessage(SkyDropdownMessageType.Open); - detectChangesFakeAsync(); + detectChangesFakeAsync(); - container = getMenuContainerElement(); + expect(isMenuItemFocused(2)).toEqual(true); + })); - expect(isElementVisible(container)).toEqual(true); + it('should close the menu after trigger button loses focus', fakeAsync(() => { + detectChangesFakeAsync(); - fixture.componentInstance.sendMessage(SkyDropdownMessageType.Close); - detectChangesFakeAsync(); + const button = getButtonElement(); - container = getMenuContainerElement(); + SkyAppTestUtility.fireDomEvent(button, 'keydown', { + keyboardEventInit: { + key: 'enter', + }, + }); - expect(container).toBeNull(); - })); + detectChangesFakeAsync(); - it('should focus the trigger button', fakeAsync(() => { - detectChangesFakeAsync(); + let container = getMenuContainerElement(); - const button = getButtonElement(); + expect(isElementVisible(container)).toEqual(true); - expect(isElementFocused(button)).toEqual(false); + // Run 'tab' on trigger button. + SkyAppTestUtility.fireDomEvent(button, 'keydown', { + keyboardEventInit: { + key: 'tab', + }, + }); + button?.blur(); - fixture.componentInstance.sendMessage( - SkyDropdownMessageType.FocusTriggerButton, - ); - detectChangesFakeAsync(); + detectChangesFakeAsync(); - expect(isElementFocused(button)).toEqual(true); - })); + container = getMenuContainerElement(); - it('should allow navigating the menu', fakeAsync(() => { - detectChangesFakeAsync(); + // Tab key should progress to next item after the trigger button. + expect(container).toBeNull(); + })); - // Open the menu. - fixture.componentInstance.sendMessage(SkyDropdownMessageType.Open); - detectChangesFakeAsync(); + it('should close the menu when tab key is pressed within the menu', fakeAsync(() => { + detectChangesFakeAsync(); - // Focus the first item. - fixture.componentInstance.sendMessage( - SkyDropdownMessageType.FocusFirstItem, - ); - detectChangesFakeAsync(); + const button = getButtonElement(); - verifyActiveMenuItemByIndex(0); - expect(isMenuItemFocused(0)).toEqual(true); + SkyAppTestUtility.fireDomEvent(button, 'keydown', { + keyboardEventInit: { + key: 'down', + }, + }); - // Focus the next item. - fixture.componentInstance.sendMessage( - SkyDropdownMessageType.FocusNextItem, - ); - detectChangesFakeAsync(); + detectChangesFakeAsync(); - // It should skip the second item because it is disabled. - verifyActiveMenuItemByIndex(2); - expect(isMenuItemFocused(2)).toEqual(true); + let container = getMenuContainerElement(); - // Focus the previous item. - fixture.componentInstance.sendMessage( - SkyDropdownMessageType.FocusPreviousItem, - ); - detectChangesFakeAsync(); + expect(isElementVisible(container)).toEqual(true); - verifyActiveMenuItemByIndex(0); - expect(isMenuItemFocused(0)).toEqual(true); - })); + const menuItems = getMenuItems(); - it('should not open the menu if disabled', fakeAsync(() => { - fixture.componentInstance.disabled = true; - detectChangesFakeAsync(); + SkyAppTestUtility.fireDomEvent(menuItems?.item(0), 'keydown', { + keyboardEventInit: { + key: 'tab', + }, + }); - // Attempt to open the menu. - fixture.componentInstance.sendMessage(SkyDropdownMessageType.Open); - detectChangesFakeAsync(); + detectChangesFakeAsync(); - const container = getMenuContainerElement(); + container = getMenuContainerElement(); - expect(container).toBeNull(); - })); + expect(container).toBeNull(); + })); + }); - it('should allow repositioning the menu', fakeAsync(() => { - detectChangesFakeAsync(); + describe('message stream', function () { + it('should open and close the menu', fakeAsync(() => { + detectChangesFakeAsync(); - // Open the menu. - fixture.componentInstance.sendMessage(SkyDropdownMessageType.Open); - detectChangesFakeAsync(); + let container = getMenuContainerElement(); - const affixSpy = spyOn(SkyAffixer.prototype, 'reaffix').and.callThrough(); + // Verify the menu is closed on startup. + expect(container).toBeNull(); - // Reposition the menu. - fixture.componentInstance.sendMessage(SkyDropdownMessageType.Reposition); - detectChangesFakeAsync(); + fixture.componentInstance.sendMessage(SkyDropdownMessageType.Open); + detectChangesFakeAsync(); - // The affixing method should be called now. - expect(affixSpy).toHaveBeenCalled(); - })); + container = getMenuContainerElement(); - it('should focus the last item', fakeAsync(() => { - detectChangesFakeAsync(); + expect(isElementVisible(container)).toEqual(true); - fixture.componentInstance.sendMessage(SkyDropdownMessageType.Open); - fixture.componentInstance.sendMessage( - SkyDropdownMessageType.FocusLastItem, - ); + fixture.componentInstance.sendMessage(SkyDropdownMessageType.Close); + detectChangesFakeAsync(); - detectChangesFakeAsync(); + container = getMenuContainerElement(); - const items = getMenuItems(); - // Set a dummy index if items doesn't exist so that the test will fail + expect(container).toBeNull(); + })); - expect(items).toExist(); - const lastItemIndex = items!.length - 1; - verifyActiveMenuItemByIndex(lastItemIndex); + it('should focus the trigger button', fakeAsync(() => { + detectChangesFakeAsync(); - expect(isMenuItemFocused(lastItemIndex)).toEqual(true); + const button = getButtonElement(); - fixture.componentInstance.items = []; + expect(isElementFocused(button)).toEqual(false); - detectChangesFakeAsync(); + fixture.componentInstance.sendMessage( + SkyDropdownMessageType.FocusTriggerButton, + ); + detectChangesFakeAsync(); - fixture.componentInstance.sendMessage( - SkyDropdownMessageType.FocusLastItem, - ); + expect(isElementFocused(button)).toEqual(true); + })); - detectChangesFakeAsync(); + it('should allow navigating the menu', fakeAsync(() => { + detectChangesFakeAsync(); - expect(getMenuElement()?.contains(document.activeElement)).toEqual( - false, - 'Requesting to focus the last item of an empty menu should not trigger focus within the menu container.', - ); - })); - }); + // Open the menu. + fixture.componentInstance.sendMessage(SkyDropdownMessageType.Open); + detectChangesFakeAsync(); - describe('accessibility', function () { - it('should set default ARIA attributes - standard dropdown', fakeAsync(() => { - detectChangesFakeAsync(); - const button = getButtonElement(); + // Focus the first item. + fixture.componentInstance.sendMessage( + SkyDropdownMessageType.FocusFirstItem, + ); + detectChangesFakeAsync(); - button?.click(); - detectChangesFakeAsync(); + verifyActiveMenuItemByIndex(0); + expect(isMenuItemFocused(0)).toEqual(true); - const menu = getMenuElement(); - const item = getFirstMenuItem(); + // Focus the next item. + fixture.componentInstance.sendMessage( + SkyDropdownMessageType.FocusNextItem, + ); + detectChangesFakeAsync(); - expect(button?.getAttribute('aria-haspopup')).toEqual( - menu?.getAttribute('role'), - ); - expect(button?.getAttribute('aria-label')).toBeNull(); - expect(button?.getAttribute('aria-expanded')).toEqual('true'); - expect(menu?.getAttribute('role')).toEqual('menu'); - expect(menu?.getAttribute('aria-labelledby')).toBeNull(); - expect(item?.getAttribute('role')).toEqual('menuitem'); - })); + // It should skip the second item because it is disabled. + verifyActiveMenuItemByIndex(2); + expect(isMenuItemFocused(2)).toEqual(true); - it('should set default ARIA attributes - context menu', fakeAsync(() => { - fixture.componentInstance.buttonType = 'context-menu'; - detectChangesFakeAsync(); - const button = getButtonElement(); + // Focus the previous item. + fixture.componentInstance.sendMessage( + SkyDropdownMessageType.FocusPreviousItem, + ); + detectChangesFakeAsync(); - button?.click(); - detectChangesFakeAsync(); + verifyActiveMenuItemByIndex(0); + expect(isMenuItemFocused(0)).toEqual(true); + })); - const menu = getMenuElement(); - const item = getFirstMenuItem(); + it('should not open the menu if disabled', fakeAsync(() => { + fixture.componentInstance.disabled = true; + detectChangesFakeAsync(); - expect(button?.getAttribute('aria-haspopup')).toEqual( - menu?.getAttribute('role'), - ); - expect(button?.getAttribute('aria-label')).toBe('Context menu'); - expect(button?.getAttribute('aria-expanded')).toEqual('true'); - expect(menu?.getAttribute('role')).toEqual('menu'); - expect(menu?.getAttribute('aria-labelledby')).toBeNull(); - expect(item?.getAttribute('role')).toEqual('menuitem'); - })); + // Attempt to open the menu. + fixture.componentInstance.sendMessage(SkyDropdownMessageType.Open); + detectChangesFakeAsync(); - it('should allow custom overrides of ARIA attributes', fakeAsync(() => { - detectChangesFakeAsync(); - const button = getButtonElement(); + const container = getMenuContainerElement(); - button?.click(); - detectChangesFakeAsync(); + expect(container).toBeNull(); + })); - fixture.componentInstance.menuAriaRole = 'menu-role-override'; - fixture.componentInstance.menuAriaLabelledBy = - 'menu-labelled-by-override'; - fixture.componentInstance.itemAriaRole = 'item-role-override'; - fixture.componentInstance.label = 'button-label-override'; + it('should allow repositioning the menu', fakeAsync(() => { + detectChangesFakeAsync(); - detectChangesFakeAsync(); - detectChangesFakeAsync(); + // Open the menu. + fixture.componentInstance.sendMessage(SkyDropdownMessageType.Open); + detectChangesFakeAsync(); - const menu = getMenuElement(); - const item = getFirstMenuItem(); + const affixSpy = spyOn( + SkyAffixer.prototype, + 'reaffix', + ).and.callThrough(); - expect(button?.getAttribute('aria-label')).toEqual( - 'button-label-override', - ); - expect(menu?.getAttribute('role')).toEqual('menu-role-override'); - expect(menu?.getAttribute('aria-labelledby')).toEqual( - 'menu-labelled-by-override', - ); - expect(item?.getAttribute('role')).toEqual('item-role-override'); - })); + // Reposition the menu. + fixture.componentInstance.sendMessage( + SkyDropdownMessageType.Reposition, + ); + detectChangesFakeAsync(); - it('should set the correct aria label when a text descriptor is specified via SkyContentProvider', fakeAsync(() => { - fixture.componentInstance.buttonType = 'context-menu'; - contentInfoProvider.patchInfo({ - descriptor: { type: 'text', value: 'Robert Hernandez' }, - }); + // The affixing method should be called now. + expect(affixSpy).toHaveBeenCalled(); + })); - detectChangesFakeAsync(); - const button = getButtonElement(); + it('should focus the last item', fakeAsync(() => { + detectChangesFakeAsync(); - expect(button?.getAttribute('aria-label')).toEqual( - 'Context menu for Robert Hernandez', - ); - })); + fixture.componentInstance.sendMessage(SkyDropdownMessageType.Open); + fixture.componentInstance.sendMessage( + SkyDropdownMessageType.FocusLastItem, + ); - it('should set the correct aria label when an elementId descriptor is specified via SkyContentProvider', fakeAsync(() => { - const contextMenuSrId = 'sr-context-menu'; - spyOn(idService, 'generateId').and.callFake(() => contextMenuSrId); - fixture.componentInstance.buttonType = 'context-menu'; - contentInfoProvider.patchInfo({ - descriptor: { type: 'elementId', value: 'sr-descriptor-label' }, - }); + detectChangesFakeAsync(); - detectChangesFakeAsync(); - const button = getButtonElement(); + const items = getMenuItems(); + // Set a dummy index if items doesn't exist so that the test will fail - expect(button?.getAttribute('aria-labelledBy')).toEqual( - `${contextMenuSrId} sr-descriptor-label`, - ); - })); + expect(items).toExist(); + const lastItemIndex = items!.length - 1; + verifyActiveMenuItemByIndex(lastItemIndex); - it('should set the correct aria label when it is specified via a consumer and a text descriptor is provided', fakeAsync(() => { - fixture.componentInstance.buttonType = 'context-menu'; - contentInfoProvider.patchInfo({ - descriptor: { type: 'text', value: 'default label' }, - }); - fixture.componentInstance.label = 'consumer label'; + expect(isMenuItemFocused(lastItemIndex)).toEqual(true); - detectChangesFakeAsync(); - const button = getButtonElement(); + fixture.componentInstance.items = []; - expect(button?.getAttribute('aria-label')).toEqual('consumer label'); - })); + detectChangesFakeAsync(); - it('should set the aria-expanded attribute', fakeAsync(() => { - detectChangesFakeAsync(); - const button = getButtonElement(); + fixture.componentInstance.sendMessage( + SkyDropdownMessageType.FocusLastItem, + ); - expect(button?.getAttribute('aria-expanded')).toEqual('false'); + detectChangesFakeAsync(); - button?.click(); - detectChangesFakeAsync(); + expect(getMenuElement()?.contains(document.activeElement)).toEqual( + false, + 'Requesting to focus the last item of an empty menu should not trigger focus within the menu container.', + ); + })); + }); - expect(button?.getAttribute('aria-expanded')).toEqual('true'); + describe('accessibility', function () { + it('should set default ARIA attributes - standard dropdown', fakeAsync(() => { + detectChangesFakeAsync(); + const button = getButtonElement(); + + button?.click(); + detectChangesFakeAsync(); + + const menu = getMenuElement(); + const item = getFirstMenuItem(); + + expect(button?.getAttribute('aria-haspopup')).toEqual( + menu?.getAttribute('role'), + ); + expect(button?.getAttribute('aria-label')).toBeNull(); + expect(button?.getAttribute('aria-expanded')).toEqual('true'); + expect(menu?.getAttribute('role')).toEqual('menu'); + expect(menu?.getAttribute('aria-labelledby')).toBeNull(); + expect(item?.getAttribute('role')).toEqual('menuitem'); + })); + + it('should set default ARIA attributes - context menu', fakeAsync(() => { + fixture.componentInstance.buttonType = 'context-menu'; + detectChangesFakeAsync(); + const button = getButtonElement(); + + button?.click(); + detectChangesFakeAsync(); + + const menu = getMenuElement(); + const item = getFirstMenuItem(); + + expect(button?.getAttribute('aria-haspopup')).toEqual( + menu?.getAttribute('role'), + ); + expect(button?.getAttribute('aria-label')).toBe('Context menu'); + expect(button?.getAttribute('aria-expanded')).toEqual('true'); + expect(menu?.getAttribute('role')).toEqual('menu'); + expect(menu?.getAttribute('aria-labelledby')).toBeNull(); + expect(item?.getAttribute('role')).toEqual('menuitem'); + })); + + it('should allow custom overrides of ARIA attributes', fakeAsync(() => { + detectChangesFakeAsync(); + const button = getButtonElement(); + + button?.click(); + detectChangesFakeAsync(); + + fixture.componentInstance.menuAriaRole = 'menu-role-override'; + fixture.componentInstance.menuAriaLabelledBy = + 'menu-labelled-by-override'; + fixture.componentInstance.itemAriaRole = 'item-role-override'; + fixture.componentInstance.label = 'button-label-override'; + + detectChangesFakeAsync(); + detectChangesFakeAsync(); + + const menu = getMenuElement(); + const item = getFirstMenuItem(); - button?.click(); - detectChangesFakeAsync(); + expect(button?.getAttribute('aria-label')).toEqual( + 'button-label-override', + ); + expect(menu?.getAttribute('role')).toEqual('menu-role-override'); + expect(menu?.getAttribute('aria-labelledby')).toEqual( + 'menu-labelled-by-override', + ); + expect(item?.getAttribute('role')).toEqual('item-role-override'); + })); + + it('should set the correct aria label when a text descriptor is specified via SkyContentProvider', fakeAsync(() => { + fixture.componentInstance.buttonType = 'context-menu'; + contentInfoProvider.patchInfo({ + descriptor: { type: 'text', value: 'Robert Hernandez' }, + }); + + detectChangesFakeAsync(); + const button = getButtonElement(); + + expect(button?.getAttribute('aria-label')).toEqual( + 'Context menu for Robert Hernandez', + ); + })); + + it('should set the correct aria label when an elementId descriptor is specified via SkyContentProvider', fakeAsync(() => { + const contextMenuSrId = 'sr-context-menu'; + spyOn(idService, 'generateId').and.callFake(() => contextMenuSrId); + fixture.componentInstance.buttonType = 'context-menu'; + contentInfoProvider.patchInfo({ + descriptor: { type: 'elementId', value: 'sr-descriptor-label' }, + }); + + detectChangesFakeAsync(); + const button = getButtonElement(); + + expect(button?.getAttribute('aria-labelledBy')).toEqual( + `${contextMenuSrId} sr-descriptor-label`, + ); + })); + + it('should set the correct aria label when it is specified via a consumer and a text descriptor is provided', fakeAsync(() => { + fixture.componentInstance.buttonType = 'context-menu'; + contentInfoProvider.patchInfo({ + descriptor: { type: 'text', value: 'default label' }, + }); + fixture.componentInstance.label = 'consumer label'; + + detectChangesFakeAsync(); + const button = getButtonElement(); + + expect(button?.getAttribute('aria-label')).toEqual('consumer label'); + })); + + it('should set the aria-expanded attribute', fakeAsync(() => { + detectChangesFakeAsync(); + const button = getButtonElement(); + + expect(button?.getAttribute('aria-expanded')).toEqual('false'); + + button?.click(); + detectChangesFakeAsync(); + + expect(button?.getAttribute('aria-expanded')).toEqual('true'); + + button?.click(); + detectChangesFakeAsync(); + + expect(button?.getAttribute('aria-expanded')).toEqual('false'); + })); + + it('should set the aria-controls attribute', fakeAsync(() => { + detectChangesFakeAsync(); + const button = getButtonElement(); + + expect(button?.getAttribute('aria-controls')).toBeNull(); + + button?.click(); + detectChangesFakeAsync(); + + const menu = getMenuElement(); + expect(button?.getAttribute('aria-controls')).toEqual( + menu?.getAttribute('id'), + ); - expect(button?.getAttribute('aria-expanded')).toEqual('false'); - })); + button?.click(); + detectChangesFakeAsync(); + + expect(button?.getAttribute('aria-controls')).toBeNull(); + })); + + it('should set the title attribute', fakeAsync(() => { + detectChangesFakeAsync(); - it('should set the aria-controls attribute', fakeAsync(() => { - detectChangesFakeAsync(); - const button = getButtonElement(); + const button = getButtonElement(); + + button?.click(); + detectChangesFakeAsync(); + + expect(button?.getAttribute('title')).toBeNull(); - expect(button?.getAttribute('aria-controls')).toBeNull(); + fixture.componentInstance.title = 'dropdown-title-override'; + detectChangesFakeAsync(); + + expect(button?.getAttribute('title')).toEqual( + 'dropdown-title-override', + ); + })); - button?.click(); - detectChangesFakeAsync(); + it('should be accessible when closed', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + await fixture.whenStable(); + await expectAsync(window.document.body).toBeAccessible({ + rules: { + region: { + enabled: false, + }, + }, + }); + }); - const menu = getMenuElement(); - expect(button?.getAttribute('aria-controls')).toEqual( - menu?.getAttribute('id'), - ); + it('should be accessible when open', async () => { + fixture.detectChanges(); - button?.click(); - detectChangesFakeAsync(); + await fixture.whenStable(); + const button = getButtonElement(); - expect(button?.getAttribute('aria-controls')).toBeNull(); - })); + button?.click(); - it('should set the title attribute', fakeAsync(() => { - detectChangesFakeAsync(); + fixture.detectChanges(); + await fixture.whenStable(); + await expectAsync(window.document.body).toBeAccessible({ + rules: { + region: { + enabled: false, + }, + }, + }); + }); + }); - const button = getButtonElement(); + if (!useCustomTrigger) { + it('should change theme', fakeAsync(() => { + detectChangesFakeAsync(); + mockThemeService.settingsChange.next({ + currentSettings: new SkyThemeSettings( + SkyTheme.presets.modern, + SkyThemeMode.presets.light, + ), + previousSettings: + mockThemeService.settingsChange.getValue().currentSettings, + }); + detectChangesFakeAsync(); + const icon = fixture.nativeElement.querySelector('.sky-i-chevron-down'); + expect(icon).toExist(); + })); + + it('should allow setting button style and type', fakeAsync(() => { + fixture.componentInstance.buttonStyle = 'danger'; + fixture.componentInstance.buttonType = 'context-menu'; + + detectChangesFakeAsync(); + + const button = getButtonElement(); + expect(button).toHaveCssClass('sky-btn-danger'); + expect(button).toHaveCssClass('sky-dropdown-button-type-context-menu'); + })); + } + } - button?.click(); - detectChangesFakeAsync(); + describe('with default trigger', () => { + runTests(false); + }); - expect(button?.getAttribute('title')).toBeNull(); + describe('with custom trigger', () => { + runTests(true); + }); +}); - fixture.componentInstance.title = 'dropdown-title-override'; - detectChangesFakeAsync(); +describe('Dropdown component without content info', function () { + function runTests(useCustomTrigger: boolean): void { + let fixture: ComponentFixture; - expect(button?.getAttribute('title')).toEqual('dropdown-title-override'); - })); + //#region helpers + + function getButtonElement(): HTMLButtonElement | null { + return fixture.nativeElement.querySelector( + useCustomTrigger ? '.custom-trigger' : '.sky-dropdown-button', + ); + } - it('should be accessible when closed', async () => { + /** + * Multiple ticks are needed to accommodate setTimeout and observable streams. + */ + function detectChangesFakeAsync(): void { fixture.detectChanges(); - await fixture.whenStable(); + tick(); fixture.detectChanges(); - await fixture.whenStable(); - await expectAsync(window.document.body).toBeAccessible({ - rules: { - region: { - enabled: false, - }, - }, + tick(); + } + + //#endregion + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [SkyDropdownFixturesModule, SkyThemeModule], }); + + fixture = TestBed.createComponent(DropdownFixtureComponent); + fixture.componentInstance.useCustomTrigger = useCustomTrigger; }); - it('should be accessible when open', async () => { - fixture.detectChanges(); + it('should set the correct aria label when SkyContentProvider is not available', fakeAsync(() => { + fixture.componentInstance.buttonType = 'context-menu'; - await fixture.whenStable(); + detectChangesFakeAsync(); const button = getButtonElement(); - button?.click(); + expect(button?.getAttribute('aria-label')).toEqual('Context menu'); + })); + } - fixture.detectChanges(); - await fixture.whenStable(); - await expectAsync(window.document.body).toBeAccessible({ - rules: { - region: { - enabled: false, - }, - }, - }); - }); + describe('with default trigger', () => { + runTests(false); + }); + + describe('with custom trigger', () => { + runTests(true); }); }); diff --git a/libs/components/popovers/src/lib/modules/dropdown/dropdown.component.ts b/libs/components/popovers/src/lib/modules/dropdown/dropdown.component.ts index 1217736256..819120b6b9 100644 --- a/libs/components/popovers/src/lib/modules/dropdown/dropdown.component.ts +++ b/libs/components/popovers/src/lib/modules/dropdown/dropdown.component.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - DestroyRef, + ContentChild, ElementRef, EnvironmentInjector, Input, @@ -18,17 +18,19 @@ import { SkyAffixHorizontalAlignment, SkyAffixService, SkyAffixer, - SkyContentInfo, SkyContentInfoProvider, + SkyIdService, SkyOverlayInstance, SkyOverlayService, } from '@skyux/core'; import { SkyThemeService } from '@skyux/theme'; -import { Observable, Subject, fromEvent as observableFromEvent } from 'rxjs'; +import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { parseAffixHorizontalAlignment } from './dropdown-extensions'; +import { SkyDropdownTriggerBaseDirective } from './dropdown-trigger-base.directive'; +import { SkyDropdownTriggerDirective } from './dropdown-trigger.directive'; import { SkyDropdownButtonType } from './types/dropdown-button-type'; import { SkyDropdownHorizontalAlignment } from './types/dropdown-horizontal-alignment'; import { SkyDropdownMessage } from './types/dropdown-message'; @@ -51,6 +53,17 @@ const DEFAULT_TRIGGER_TYPE: SkyDropdownTriggerType = 'click'; standalone: false, }) export class SkyDropdownComponent implements OnInit, OnDestroy { + readonly #affixService = inject(SkyAffixService); + readonly #changeDetector = inject(ChangeDetectorRef); + readonly #environmentInjector = inject(EnvironmentInjector); + readonly #idSvc = inject(SkyIdService); + readonly #overlayService = inject(SkyOverlayService); + readonly #themeSvc = inject(SkyThemeService, { optional: true }); + readonly #zIndex = inject(SKY_STACKING_CONTEXT, { optional: true })?.zIndex; + readonly #contentInfoProvider = inject(SkyContentInfoProvider, { + optional: true, + }); + /** * The background color for the dropdown button. Available values are `default`, * `primary`, and `link`. These values set the background color and hover behavior from the @@ -87,7 +100,14 @@ export class SkyDropdownComponent implements OnInit, OnDestroy { * @default false */ @Input() - public disabled: boolean | undefined = false; + public set disabled(value: boolean | undefined) { + this.#_disabled = value; + this.#updateTrigger(); + } + + public get disabled(): boolean | undefined { + return this.#_disabled; + } /** * The ARIA label for the dropdown. This sets the dropdown's `aria-label` attribute to provide a text equivalent for screen readers @@ -96,9 +116,16 @@ export class SkyDropdownComponent implements OnInit, OnDestroy { * For more information about the `aria-label` attribute, see the [WAI-ARIA definition](https://www.w3.org/TR/wai-aria/#aria-label). */ @Input() - public label: string | undefined; + public set label(value: string | undefined) { + this.#_label = value; + this.#updateTrigger(); + } + + public get label(): string | undefined { + return this.#_label; + } - protected contentInfoObs: Observable | undefined; + protected readonly contentInfoObs = this.#contentInfoProvider?.getInfo(); /** * The horizontal alignment of the dropdown menu in relation to the dropdown button. @@ -128,7 +155,14 @@ export class SkyDropdownComponent implements OnInit, OnDestroy { * The title to display in a tooltip when users hover the mouse over the dropdown button. */ @Input() - public title: string | undefined; + public set title(value: string | undefined) { + this.#_title = value; + this.#updateTrigger(); + } + + public get title(): string | undefined { + return this.#_title; + } /** * How users interact with the dropdown button to expose the dropdown menu. @@ -151,6 +185,7 @@ export class SkyDropdownComponent implements OnInit, OnDestroy { public set isOpen(value: boolean) { this.#_isOpen = value; + this.#updateTrigger(); this.#changeDetector.markForCheck(); } @@ -169,13 +204,27 @@ export class SkyDropdownComponent implements OnInit, OnDestroy { } } - public isMouseEnter = false; + public set menuId(value: string | undefined) { + this.#_menuId = value; + this.#updateTrigger(); + } - public isVisible = false; + public get menuId(): string | undefined { + return this.#_menuId; + } + + public set menuAriaRole(value: string | undefined) { + this.#_menuAriaRole = value; + this.#updateTrigger(); + } + + public get menuAriaRole(): string | undefined { + return this.#_menuAriaRole; + } - public menuId: string | undefined; + public isMouseEnter = false; - public menuAriaRole: string | undefined; + public isVisible = false; @ViewChild('menuContainerTemplateRef', { read: TemplateRef, @@ -184,45 +233,50 @@ export class SkyDropdownComponent implements OnInit, OnDestroy { public menuContainerTemplateRef: TemplateRef | undefined; @ViewChild('triggerButton', { - read: ElementRef, - static: true, + read: SkyDropdownTriggerBaseDirective, }) - public set triggerButton(value: ElementRef | undefined) { + public set triggerButton(value: SkyDropdownTriggerBaseDirective | undefined) { this.#_triggerButton = value; this.#addEventListeners(); + this.#updateTrigger(); } - public get triggerButton(): ElementRef | undefined { - return this.#_triggerButton; + public get triggerButton(): + | SkyDropdownTriggerBaseDirective + | SkyDropdownTriggerDirective + | undefined { + return this.#_customTriggerButton ?? this.#_triggerButton; } - protected destroyRef = inject(DestroyRef); + @ContentChild(SkyDropdownTriggerDirective) + public set customTriggerButton( + value: SkyDropdownTriggerDirective | undefined, + ) { + this.#_customTriggerButton = value; + this.#addEventListeners(); + this.#updateTrigger(); + } + + protected screenReaderLabelContextMenuId = this.#idSvc.generateId(); #affixer: SkyAffixer | undefined; - #overlay: SkyOverlayInstance | undefined; #ngUnsubscribe = new Subject(); + #triggerUnsubscribe = new Subject(); + #overlay: SkyOverlayInstance | undefined; #positionTimeout: number | undefined; - readonly #affixService = inject(SkyAffixService); - readonly #changeDetector = inject(ChangeDetectorRef); - readonly #environmentInjector = inject(EnvironmentInjector); - readonly #overlayService = inject(SkyOverlayService); - readonly #themeSvc = inject(SkyThemeService, { optional: true }); - readonly #zIndex = inject(SKY_STACKING_CONTEXT, { optional: true })?.zIndex; - readonly #contentInfoProvider = inject(SkyContentInfoProvider, { - optional: true, - }); - #_buttonStyle = DEFAULT_BUTTON_STYLE; #_buttonType = DEFAULT_BUTTON_TYPE; + #_customTriggerButton: SkyDropdownTriggerDirective | undefined; + #_disabled: boolean | undefined = false; #_horizontalAlignment = DEFAULT_HORIZONTAL_ALIGNMENT; #_isOpen = false; + #_label: string | undefined; + #_menuAriaRole: string | undefined; + #_menuId: string | undefined; + #_title: string | undefined; #_trigger = DEFAULT_TRIGGER_TYPE; - #_triggerButton: ElementRef | undefined; - - constructor() { - this.contentInfoObs = this.#contentInfoProvider?.getInfo(); - } + #_triggerButton: SkyDropdownTriggerBaseDirective | undefined; public ngOnInit(): void { this.messageStream @@ -246,16 +300,39 @@ export class SkyDropdownComponent implements OnInit, OnDestroy { this.#ngUnsubscribe.next(); this.#ngUnsubscribe.complete(); + + this.#triggerUnsubscribe.next(); + this.#triggerUnsubscribe.complete(); + } + + #updateTrigger(): void { + const triggerButton = this.triggerButton; + + if (triggerButton) { + triggerButton.buttonType.set(this.buttonType); + triggerButton.disabled.set(this.disabled); + triggerButton.isOpen.set(this.isOpen); + triggerButton.label.set(this.label); + triggerButton.menuAriaRole.set(this.menuAriaRole); + triggerButton.menuId.set(this.menuId); + triggerButton.title.set(this.title); + triggerButton.disabled.set(this.disabled); + triggerButton.screenReaderLabelContextMenuId.set( + this.screenReaderLabelContextMenuId, + ); + } } #addEventListeners(): void { - this.#ngUnsubscribe.next(); - this.#ngUnsubscribe.complete(); - this.#ngUnsubscribe = new Subject(); - const buttonElement = this.triggerButton?.nativeElement; - if (buttonElement) { - observableFromEvent(buttonElement, 'click') - .pipe(takeUntil(this.#ngUnsubscribe)) + this.#triggerUnsubscribe.next(); + this.#triggerUnsubscribe.complete(); + + this.#triggerUnsubscribe = new Subject(); + const triggerButton = this.triggerButton; + + if (triggerButton) { + triggerButton.triggerClick + .pipe(takeUntil(this.#triggerUnsubscribe)) .subscribe(() => { if (this.isOpen) { this.#sendMessage(SkyDropdownMessageType.Close); @@ -268,54 +345,37 @@ export class SkyDropdownComponent implements OnInit, OnDestroy { } }); - observableFromEvent(buttonElement, 'keydown') - .pipe(takeUntil(this.#ngUnsubscribe)) + triggerButton.triggerKeyDown + .pipe(takeUntil(this.#triggerUnsubscribe)) .subscribe((event) => { const key = event.key.toLowerCase(); switch (key) { case 'escape': /*istanbul ignore else*/ - if (this.isOpen) { - this.#sendMessage(SkyDropdownMessageType.Close); - this.#sendMessage(SkyDropdownMessageType.FocusTriggerButton); - event.stopPropagation(); - } + this.#handleTriggerEscape(event); break; case 'tab': - if (this.isOpen) { - this.#sendMessage(SkyDropdownMessageType.Close); - } + this.#handleTriggerTab(); break; case 'arrowup': case 'up': - if (!this.isOpen) { - this.#sendMessage(SkyDropdownMessageType.Open); - this.#sendMessage(SkyDropdownMessageType.FocusLastItem); - event.preventDefault(); - event.stopPropagation(); - } + this.#handleTriggerUp(event); break; case 'enter': case 'arrowdown': case 'down': case ' ': // Spacebar. - /*istanbul ignore else*/ - if (!this.isOpen) { - this.#sendMessage(SkyDropdownMessageType.Open); - this.#sendMessage(SkyDropdownMessageType.FocusFirstItem); - event.preventDefault(); - event.stopPropagation(); - } + this.#handleTriggerDown(event); break; } }); - observableFromEvent(buttonElement, 'mouseenter') - .pipe(takeUntil(this.#ngUnsubscribe)) + triggerButton.triggerMouseEnter + .pipe(takeUntil(this.#triggerUnsubscribe)) .subscribe(() => { this.isMouseEnter = true; if (this.trigger === 'hover') { @@ -323,8 +383,8 @@ export class SkyDropdownComponent implements OnInit, OnDestroy { } }); - observableFromEvent(buttonElement, 'mouseleave') - .pipe(takeUntil(this.#ngUnsubscribe)) + triggerButton.triggerMouseLeave + .pipe(takeUntil(this.#triggerUnsubscribe)) .subscribe(() => { this.isMouseEnter = false; if (this.trigger === 'hover') { @@ -340,6 +400,39 @@ export class SkyDropdownComponent implements OnInit, OnDestroy { } } + #handleTriggerEscape(event: KeyboardEvent): void { + if (this.isOpen) { + this.#sendMessage(SkyDropdownMessageType.Close); + this.#sendMessage(SkyDropdownMessageType.FocusTriggerButton); + event.stopPropagation(); + } + } + + #handleTriggerTab(): void { + if (this.isOpen) { + this.#sendMessage(SkyDropdownMessageType.Close); + } + } + + #handleTriggerUp(event: KeyboardEvent): void { + if (!this.isOpen) { + this.#sendMessage(SkyDropdownMessageType.Open); + this.#sendMessage(SkyDropdownMessageType.FocusLastItem); + event.preventDefault(); + event.stopPropagation(); + } + } + + #handleTriggerDown(event: KeyboardEvent): void { + /*istanbul ignore else*/ + if (!this.isOpen) { + this.#sendMessage(SkyDropdownMessageType.Open); + this.#sendMessage(SkyDropdownMessageType.FocusFirstItem); + event.preventDefault(); + event.stopPropagation(); + } + } + #createOverlay(): void { if (this.#overlay) { return; diff --git a/libs/components/popovers/src/lib/modules/dropdown/dropdown.module.ts b/libs/components/popovers/src/lib/modules/dropdown/dropdown.module.ts index df78ec8bbd..1ee9349229 100644 --- a/libs/components/popovers/src/lib/modules/dropdown/dropdown.module.ts +++ b/libs/components/popovers/src/lib/modules/dropdown/dropdown.module.ts @@ -13,6 +13,8 @@ import { SkyPopoversResourcesModule } from '../shared/sky-popovers-resources.mod import { SkyDropdownButtonComponent } from './dropdown-button.component'; import { SkyDropdownItemComponent } from './dropdown-item.component'; import { SkyDropdownMenuComponent } from './dropdown-menu.component'; +import { SkyDropdownTriggerBaseDirective } from './dropdown-trigger-base.directive'; +import { SkyDropdownTriggerDirective } from './dropdown-trigger.directive'; import { SkyDropdownComponent } from './dropdown.component'; @NgModule({ @@ -25,6 +27,8 @@ import { SkyDropdownComponent } from './dropdown.component'; imports: [ CommonModule, SkyAffixModule, + SkyDropdownTriggerDirective, + SkyDropdownTriggerBaseDirective, SkyIconModule, SkyIdModule, SkyPopoversResourcesModule, @@ -36,6 +40,7 @@ import { SkyDropdownComponent } from './dropdown.component'; SkyDropdownComponent, SkyDropdownItemComponent, SkyDropdownMenuComponent, + SkyDropdownTriggerDirective, ], }) export class SkyDropdownModule {} diff --git a/libs/components/popovers/src/lib/modules/dropdown/fixtures/dropdown.component.fixture.html b/libs/components/popovers/src/lib/modules/dropdown/fixtures/dropdown.component.fixture.html index 7380aa2758..f950bbdaee 100644 --- a/libs/components/popovers/src/lib/modules/dropdown/fixtures/dropdown.component.fixture.html +++ b/libs/components/popovers/src/lib/modules/dropdown/fixtures/dropdown.component.fixture.html @@ -1,5 +1,4 @@ -@if (show) { - +@if (show) { @if (useCustomTrigger) { - Show dropdown + -} +} @else { + Show dropdown + + + @for (item of items; track item) { + + + + } + + +} } diff --git a/libs/components/popovers/src/lib/modules/dropdown/fixtures/dropdown.component.fixture.ts b/libs/components/popovers/src/lib/modules/dropdown/fixtures/dropdown.component.fixture.ts index 243da809a1..17abedde35 100644 --- a/libs/components/popovers/src/lib/modules/dropdown/fixtures/dropdown.component.fixture.ts +++ b/libs/components/popovers/src/lib/modules/dropdown/fixtures/dropdown.component.fixture.ts @@ -49,6 +49,8 @@ export class DropdownFixtureComponent { public useNativeFocus: boolean | undefined; + public useCustomTrigger = false; + //#endregion directive properties @ViewChild('dropdownRef', { From c266930f2995026d4dbe1b75a3348f9ec5bc0053 Mon Sep 17 00:00:00 2001 From: Blackbaud Sky Build User Date: Tue, 11 Feb 2025 15:35:29 -0500 Subject: [PATCH 16/20] feat(components/core): add SkyViewkeeper support for SkyAppViewportService properties (#3120) (#3143) :cherries: Cherry picked from #3120 [feat(components/core): add SkyViewkeeper support for SkyAppViewportService properties](https://github.com/blackbaud/skyux/pull/3120) [AB#3248387](https://dev.azure.com/blackbaud/f565481a-7bc9-4083-95d5-4f953da6d499/_workitems/edit/3248387) Co-authored-by: John White <750350+johnhwhite@users.noreply.github.com> --- .../lib/modules/dock/dock.component.spec.ts | 15 ++- .../viewkeeper/viewkeeper-host-options.ts | 2 + .../modules/viewkeeper/viewkeeper-options.ts | 5 + .../lib/modules/viewkeeper/viewkeeper.spec.ts | 38 +++++- .../src/lib/modules/viewkeeper/viewkeeper.ts | 24 +++- .../src/lib/viewport/viewport-reserve-args.ts | 5 + .../src/lib/viewport/viewport.service.spec.ts | 102 ++++++++++++++- .../src/lib/viewport/viewport.service.ts | 123 ++++++++++++++---- scripts/publish-local.ts | 1 + 9 files changed, 280 insertions(+), 35 deletions(-) diff --git a/libs/components/core/src/lib/modules/dock/dock.component.spec.ts b/libs/components/core/src/lib/modules/dock/dock.component.spec.ts index c3b5878fc0..968c9d2347 100644 --- a/libs/components/core/src/lib/modules/dock/dock.component.spec.ts +++ b/libs/components/core/src/lib/modules/dock/dock.component.spec.ts @@ -24,6 +24,12 @@ const STYLE_ELEMENT_SELECTOR = const isIE = window.navigator.userAgent.indexOf('rv:11.0') >= 0; +// 16ms is the fakeAsync time for requestAnimationFrame, simulating ~60fps. +// https://github.com/angular/angular/blob/19.1.x/packages/zone.js/lib/zone-spec/fake-async-test.ts#L682-L693 +function tickForAnimationFrame(): void { + tick(16); +} + describe('Dock component', () => { let fixture: ComponentFixture; let mutationCallbacks: (() => void)[]; @@ -38,7 +44,7 @@ describe('Dock component', () => { fixture.detectChanges(); fixture.componentInstance.itemConfigs = itemConfigs; fixture.detectChanges(); - tick(); + tickForAnimationFrame(); } /** @@ -70,7 +76,7 @@ describe('Dock component', () => { fixture.detectChanges(); tick(250); // Respect the RxJS debounceTime. fixture.detectChanges(); - tick(); + tickForAnimationFrame(); } function getStyleElement(): HTMLStyleElement { @@ -343,6 +349,7 @@ describe('Dock component', () => { reserveSpace('left', 20); fixture.detectChanges(); + tickForAnimationFrame(); const dockEl = getDockEl(); const actionBarBounds = dockEl.getBoundingClientRect(); @@ -370,7 +377,7 @@ describe('Dock component', () => { ]); fixture.detectChanges(); - tick(); + tickForAnimationFrame(); const dockStyle = getDockStyle(); @@ -398,7 +405,7 @@ describe('Dock component', () => { ]); fixture.detectChanges(); - tick(); + tickForAnimationFrame(); const dockStyle = getDockStyle(); diff --git a/libs/components/core/src/lib/modules/viewkeeper/viewkeeper-host-options.ts b/libs/components/core/src/lib/modules/viewkeeper/viewkeeper-host-options.ts index 91bf352491..1826c598cd 100644 --- a/libs/components/core/src/lib/modules/viewkeeper/viewkeeper-host-options.ts +++ b/libs/components/core/src/lib/modules/viewkeeper/viewkeeper-host-options.ts @@ -17,4 +17,6 @@ export class SkyViewkeeperHostOptions implements SkyViewkeeperOptions { public verticalOffsetEl?: HTMLElement; public viewportMarginTop?: number; + + public viewportMarginProperty?: `--${string}`; } diff --git a/libs/components/core/src/lib/modules/viewkeeper/viewkeeper-options.ts b/libs/components/core/src/lib/modules/viewkeeper/viewkeeper-options.ts index c97a1e8213..be1dc3196e 100644 --- a/libs/components/core/src/lib/modules/viewkeeper/viewkeeper-options.ts +++ b/libs/components/core/src/lib/modules/viewkeeper/viewkeeper-options.ts @@ -44,4 +44,9 @@ export interface SkyViewkeeperOptions { * Reserved space in pixels at the top of the viewport. */ viewportMarginTop?: number; + + /** + * Custom CSS property for reserved space at the top of the viewport. + */ + viewportMarginProperty?: `--${string}`; } diff --git a/libs/components/core/src/lib/modules/viewkeeper/viewkeeper.spec.ts b/libs/components/core/src/lib/modules/viewkeeper/viewkeeper.spec.ts index 3ed1cf034e..769fce0747 100644 --- a/libs/components/core/src/lib/modules/viewkeeper/viewkeeper.spec.ts +++ b/libs/components/core/src/lib/modules/viewkeeper/viewkeeper.spec.ts @@ -9,12 +9,20 @@ describe('Viewkeeper', () => { let vks: SkyViewkeeper[]; function scrollWindowTo(x: number, y: number): void { - window.scrollTo(x, y); + window.scrollTo({ + top: y, + left: x, + behavior: 'instant', + }); SkyAppTestUtility.fireDomEvent(window, 'scroll'); } function scrollScrollableHost(x: number, y: number): void { - scrollableHostEl.scrollTo(x, y); + scrollableHostEl.scrollTo({ + top: y, + left: x, + behavior: 'instant', + }); SkyAppTestUtility.fireDomEvent(scrollableHostEl, 'scroll'); } @@ -213,6 +221,32 @@ describe('Viewkeeper', () => { ); }); + it('should support viewportMarginProperty', () => { + vks.push( + new SkyViewkeeper({ + el, + boundaryEl, + viewportMarginProperty: '--test-viewport-top', + setWidth: true, + }), + ); + + scrollWindowTo(0, 10); + validatePinned(el, false); + document.documentElement.style.setProperty('--test-viewport-top', '2px'); + scrollWindowTo(40, 100); + validatePinned(el, true, 0, 2); + expect(el.style.marginTop).toBe( + 'calc(0px + var(--test-viewport-top, 0))', + ); + + document.documentElement.style.setProperty('--test-viewport-top', '12px'); + validatePinned(el, true, 0, 12); + + document.documentElement.style.removeProperty('--test-viewport-top'); + document.body.style.removeProperty('--test-viewport-top'); + }); + describe('ResizeObserver', () => { const NativeResizeObserver = ResizeObserver; diff --git a/libs/components/core/src/lib/modules/viewkeeper/viewkeeper.ts b/libs/components/core/src/lib/modules/viewkeeper/viewkeeper.ts index 8f3216dd79..3c288cda4a 100644 --- a/libs/components/core/src/lib/modules/viewkeeper/viewkeeper.ts +++ b/libs/components/core/src/lib/modules/viewkeeper/viewkeeper.ts @@ -68,12 +68,15 @@ function setElPosition( top: number | string, width: number | string, marginTop: number | string, + marginTopProperty: string | undefined, clipTop: number | string, clipLeft: number | string, ): void { el.style.top = px(top); el.style.left = px(left); - el.style.marginTop = px(marginTop); + el.style.marginTop = marginTopProperty + ? `calc(${px(marginTop)} + var(${marginTopProperty}, 0))` + : px(marginTop); el.style.clipPath = clipTop || clipLeft ? `inset(${px(clipTop)} 0 0 ${px(clipLeft)})` : 'none'; @@ -117,6 +120,8 @@ export class SkyViewkeeper { #viewportMarginTop = 0; + #viewportMarginProperty: `--${string}` | undefined; + #currentElFixedLeft: number | undefined; #currentElFixedTop: number | undefined; @@ -163,6 +168,7 @@ export class SkyViewkeeper { // Only set viewport margin if the scrollable host is undefined. if (!this.#scrollableHost) { this.#viewportMarginTop = options.viewportMarginTop ?? 0; + this.#viewportMarginProperty = options.viewportMarginProperty; } this.#syncElPositionHandler = (): void => @@ -272,7 +278,7 @@ export class SkyViewkeeper { width = 'auto'; } - setElPosition(el, '', '', width, '', 0, 0); + setElPosition(el, '', '', width, '', '', 0, 0); } #calculateVerticalOffset(): number { @@ -303,9 +309,18 @@ export class SkyViewkeeper { anchorTop = getOffset(el, this.#scrollableHost).top; } + let viewportMarginTop = this.#viewportMarginTop; + const viewportMarginProperty = + this.#viewportMarginProperty && + getComputedStyle(document.body).getPropertyValue( + this.#viewportMarginProperty, + ); + if (viewportMarginProperty) { + viewportMarginTop += parseInt(viewportMarginProperty, 10); + } + const doFixEl = - boundaryInfo.scrollTop + verticalOffset + this.#viewportMarginTop > - anchorTop; + boundaryInfo.scrollTop + verticalOffset + viewportMarginTop > anchorTop; return doFixEl; } @@ -413,6 +428,7 @@ export class SkyViewkeeper { fixedStyles.elFixedTop, width, this.#viewportMarginTop, + this.#viewportMarginProperty, fixedStyles.elClipTop, fixedStyles.elClipLeft, ); diff --git a/libs/components/theme/src/lib/viewport/viewport-reserve-args.ts b/libs/components/theme/src/lib/viewport/viewport-reserve-args.ts index c1758b4a8c..2511cafb15 100644 --- a/libs/components/theme/src/lib/viewport/viewport-reserve-args.ts +++ b/libs/components/theme/src/lib/viewport/viewport-reserve-args.ts @@ -18,4 +18,9 @@ export interface SkyAppViewportReserveArgs { * The number of pixels to reserve. */ size: number; + + /** + * Only reserve space when this element is in view. + */ + reserveForElement?: HTMLElement; } diff --git a/libs/components/theme/src/lib/viewport/viewport.service.spec.ts b/libs/components/theme/src/lib/viewport/viewport.service.spec.ts index 09ef496593..baef7b6c40 100644 --- a/libs/components/theme/src/lib/viewport/viewport.service.spec.ts +++ b/libs/components/theme/src/lib/viewport/viewport.service.spec.ts @@ -1,3 +1,5 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; + import { ReplaySubject } from 'rxjs'; import { SkyAppViewportReservedPositionType } from './viewport-reserve-position-type'; @@ -18,14 +20,14 @@ describe('Viewport service', () => { } beforeEach(() => { - svc = new SkyAppViewportService(document); + svc = TestBed.inject(SkyAppViewportService); }); it('should return an observable when the content is visible', () => { expect(svc.visible instanceof ReplaySubject).toEqual(true); }); - it('should reserve and unreserve space', () => { + it('should reserve and unreserve space', async () => { svc.reserveSpace({ id: 'left-test', position: 'left', @@ -50,6 +52,7 @@ describe('Viewport service', () => { size: 50, }); + await new Promise((resolve) => requestAnimationFrame(resolve)); validateViewportSpace('left', 20); validateViewportSpace('top', 30); validateViewportSpace('right', 40); @@ -60,9 +63,104 @@ describe('Viewport service', () => { svc.unreserveSpace('right-test'); svc.unreserveSpace('bottom-test'); + await new Promise((resolve) => requestAnimationFrame(resolve)); validateViewportSpace('left', 0); validateViewportSpace('top', 0); validateViewportSpace('right', 0); validateViewportSpace('bottom', 0); }); + + it('should reserve and unreserve space for elements that scroll out of view', waitForAsync(async () => { + const viewportHeight = window.innerHeight; + + expect(viewportHeight).toBeGreaterThan(50); + + function isInViewport(element: Element): boolean { + const rect = element.getBoundingClientRect(); + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= + (window.innerHeight || document.documentElement.clientHeight) && + rect.right <= + (window.innerWidth || document.documentElement.clientWidth) + ); + } + + const container = document.createElement('div'); + container.style.height = '200vh'; + container.style.position = 'relative'; + container.style.marginTop = '20px'; + container.appendChild(document.createTextNode('Scroll down')); + + const item1 = document.createElement('div'); + item1.style.backgroundColor = 'lightblue'; + item1.style.height = '50px'; + item1.style.width = '100px'; + item1.style.overflow = 'hidden'; + item1.style.position = 'absolute'; + item1.style.top = '0'; + item1.style.left = '0'; + item1.appendChild(document.createTextNode('Item 1')); + container.appendChild(item1); + + const item2 = document.createElement('div'); + // eslint-disable-next-line @cspell/spellchecker + item2.style.backgroundColor = 'lightgreen'; + item2.style.height = '54px'; + item2.style.width = '100px'; + item2.style.overflow = 'hidden'; + item2.style.position = 'absolute'; + item2.style.top = `${viewportHeight + 50}px`; + item2.style.left = '0'; + item2.appendChild(document.createTextNode('Item 2')); + container.appendChild(item2); + + document.body.appendChild(container); + + svc.reserveSpace({ + id: 'item1-test', + position: 'top', + size: 50, + reserveForElement: item1, + }); + + svc.reserveSpace({ + id: 'item2-test', + position: 'top', + size: 54, + reserveForElement: item2, + }); + + await new Promise((resolve) => setTimeout(resolve, 32)); + await new Promise((resolve) => requestAnimationFrame(resolve)); + expect(isInViewport(item1)).toBeTrue(); + validateViewportSpace('top', 50); + window.scrollTo({ + top: viewportHeight + 50, + behavior: 'instant', + }); + await new Promise((resolve) => requestAnimationFrame(resolve)); + await new Promise((resolve) => requestAnimationFrame(resolve)); + expect(isInViewport(item1)).toBeFalse(); + expect(isInViewport(item2)).toBeTrue(); + validateViewportSpace('top', 54); + + svc.unreserveSpace('item2-test'); + await new Promise((resolve) => requestAnimationFrame(resolve)); + validateViewportSpace('top', 0); + window.scrollTo({ + top: 0, + behavior: 'instant', + }); + await new Promise((resolve) => setTimeout(resolve, 32)); + await new Promise((resolve) => requestAnimationFrame(resolve)); + expect(isInViewport(item1)).toBeTrue(); + validateViewportSpace('top', 50); + svc.unreserveSpace('item1-test'); + await new Promise((resolve) => requestAnimationFrame(resolve)); + validateViewportSpace('top', 0); + + document.body.removeChild(container); + })); }); diff --git a/libs/components/theme/src/lib/viewport/viewport.service.ts b/libs/components/theme/src/lib/viewport/viewport.service.ts index de100b9e84..08cf20e117 100644 --- a/libs/components/theme/src/lib/viewport/viewport.service.ts +++ b/libs/components/theme/src/lib/viewport/viewport.service.ts @@ -1,11 +1,15 @@ import { DOCUMENT } from '@angular/common'; -import { Inject, Injectable } from '@angular/core'; +import { DestroyRef, Injectable, inject } from '@angular/core'; import { ReplaySubject } from 'rxjs'; import { SkyAppViewportReserveArgs } from './viewport-reserve-args'; import { SkyAppViewportReservedPositionType } from './viewport-reserve-position-type'; +type ReserveItemType = SkyAppViewportReserveArgs & { + active: boolean; +}; + /** * Provides information about the state of the application's viewport. */ @@ -21,11 +25,39 @@ export class SkyAppViewportService { */ public visible = new ReplaySubject(1); - #reserveItems = new Map(); - #document: Document; + // ESLint doesn't recognize how this is used. + // eslint-disable-next-line no-unused-private-class-members + #updateRequest: number | undefined = undefined; + readonly #reserveItems = new Map(); + readonly #conditionallyReserveItems = new Map(); + readonly #document = inject(DOCUMENT); + readonly #intersectionObserver = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + const item = this.#conditionallyReserveItems.get(entry.target); + if (item) { + item.active = entry.isIntersecting; + } + }); + this.#updateViewportArea(); + }, + { + root: this.#document, + threshold: Array.from({ length: 11 }, (_, i) => i / 10), + }, + ); - constructor(@Inject(DOCUMENT) document: Document) { - this.#document = document; + constructor() { + const onScroll = (): void => { + if (this.#conditionallyReserveItems.size > 0) { + this.#updateViewportArea(); + } + }; + this.#document.addEventListener('scroll', onScroll); + inject(DestroyRef).onDestroy(() => { + this.#intersectionObserver.disconnect(); + this.#document.removeEventListener('scroll', onScroll); + }); } /** @@ -33,7 +65,12 @@ export class SkyAppViewportService { * @param args */ public reserveSpace(args: SkyAppViewportReserveArgs): void { - this.#reserveItems.set(args.id, args); + const item = { + ...args, + active: !args.reserveForElement, + }; + this.#reserveItems.set(args.id, item); + this.#watchVisibility(item); this.#updateViewportArea(); } @@ -42,31 +79,71 @@ export class SkyAppViewportService { * @param id */ public unreserveSpace(id: string): void { + const args = this.#reserveItems.get(id); + if (args?.reserveForElement) { + this.#intersectionObserver.unobserve(args.reserveForElement); + this.#conditionallyReserveItems.delete(args.reserveForElement); + } this.#reserveItems.delete(id); this.#updateViewportArea(); } #updateViewportArea(): void { - const reservedSpaces: { - [key in SkyAppViewportReservedPositionType]: number; - } = { - bottom: 0, - left: 0, - right: 0, - top: 0, - }; + this.#updateRequest ??= requestAnimationFrame(() => { + const reservedSpaces: { + [key in SkyAppViewportReservedPositionType]: number; + } = { + bottom: 0, + left: 0, + right: 0, + top: 0, + }; - for (const { position, size } of this.#reserveItems.values()) { - reservedSpaces[position] += size; - } + for (const { + position, + size, + active, + reserveForElement, + } of this.#reserveItems.values()) { + if ( + active && + (!reserveForElement || this.#isElementVisible(reserveForElement)) + ) { + reservedSpaces[position] += size; + } + } - const documentElementStyle = this.#document.documentElement.style; + const documentElementStyle = this.#document.documentElement.style; - for (const [position, size] of Object.entries(reservedSpaces)) { - documentElementStyle.setProperty( - `--sky-viewport-${position}`, - size + 'px', - ); + for (const [position, size] of Object.entries(reservedSpaces)) { + documentElementStyle.setProperty( + `--sky-viewport-${position}`, + size + 'px', + ); + } + + this.#updateRequest = undefined; + }); + } + + #watchVisibility(item: ReserveItemType): void { + if (item.reserveForElement) { + this.#conditionallyReserveItems.set(item.reserveForElement, item); + this.#intersectionObserver.observe(item.reserveForElement); } } + + #isElementVisible(element: HTMLElement): boolean { + const rect = element.getBoundingClientRect(); + return ( + // Vertically in view + rect.y <= window.innerHeight && + rect.y >= 0 && + // Horizontally in view + rect.x <= window.innerWidth && + rect.x >= 0 && + // Element is not hidden by another element + this.#document.elementFromPoint(rect.x + 1, rect.y + 1) === element + ); + } } diff --git a/scripts/publish-local.ts b/scripts/publish-local.ts index cda5c4c511..75b66922e1 100644 --- a/scripts/publish-local.ts +++ b/scripts/publish-local.ts @@ -40,6 +40,7 @@ async function skyuxDevCommand( const npmConfig = [ [`@skyux:registry`, `http:${localRepo}`], [`@skyux-sdk:registry`, `http:${localRepo}`], + [`registry`, `http:${localRepo}`], // Having an auth token is required for publishing to a local registry, even if it's not used. [`${localRepo}:_authToken`, `YXV0aFRva2Vu`], ]; From 86d699bce557020e3d432845f5a6cf3d724b46f1 Mon Sep 17 00:00:00 2001 From: Blackbaud Sky Build User Date: Tue, 11 Feb 2025 16:12:04 -0500 Subject: [PATCH 17/20] feat(components/popovers): add support for custom trigger buttons to dropdown harness (#3129) (#3137) :cherries: Cherry picked from #3129 [feat(components/popovers): add support for custom trigger buttons to dropdown harness](https://github.com/blackbaud/skyux/pull/3129) Co-authored-by: Paul Crowder --- .../modules/dropdown/dropdown-harness.spec.ts | 250 ++++++++++-------- .../src/modules/dropdown/dropdown-harness.ts | 7 +- 2 files changed, 140 insertions(+), 117 deletions(-) diff --git a/libs/components/popovers/testing/src/modules/dropdown/dropdown-harness.spec.ts b/libs/components/popovers/testing/src/modules/dropdown/dropdown-harness.spec.ts index 37674448d4..7aab5ab580 100644 --- a/libs/components/popovers/testing/src/modules/dropdown/dropdown-harness.spec.ts +++ b/libs/components/popovers/testing/src/modules/dropdown/dropdown-harness.spec.ts @@ -20,6 +20,16 @@ import { SkyDropdownHarness } from './dropdown-harness'; + + + + + @@ -38,11 +48,7 @@ class TestDropdownComponent { // #endregion Test component describe('Dropdown test harness', () => { - async function setupTest( - options: { - dataSkyId?: string; - } = {}, - ): Promise<{ + async function setupTest(dataSkyId?: string): Promise<{ dropdownHarness: SkyDropdownHarness; fixture: ComponentFixture; loader: HarnessLoader; @@ -57,10 +63,10 @@ describe('Dropdown test harness', () => { let dropdownHarness: SkyDropdownHarness; - if (options.dataSkyId) { + if (dataSkyId) { dropdownHarness = await loader.getHarness( SkyDropdownHarness.with({ - dataSkyId: options.dataSkyId, + dataSkyId: dataSkyId, }), ); } else { @@ -70,166 +76,178 @@ describe('Dropdown test harness', () => { return { dropdownHarness, fixture, loader }; } - it('should get the dropdown from its data-sky-id', async () => { - const { dropdownHarness, fixture } = await setupTest({ - dataSkyId: 'other-dropdown', + function runTriggerTests(dataSkyId?: string): void { + it('should get the default value for disabled button', async () => { + const { dropdownHarness, fixture } = await setupTest(dataSkyId); + + fixture.detectChanges(); + + await expectAsync(dropdownHarness.isDisabled()).toBeResolvedTo(false); }); - fixture.detectChanges(); + it('should get the correct value for disabled button', async () => { + const { dropdownHarness, fixture } = await setupTest(dataSkyId); - await expectAsync(dropdownHarness.getButtonStyle()).toBeResolvedTo( - 'primary', - ); - }); + fixture.componentInstance.disabledFlag = true; + fixture.detectChanges(); - it('should get the default button style', async () => { - const { dropdownHarness, fixture } = await setupTest(); + await expectAsync(dropdownHarness.isDisabled()).toBeResolvedTo(true); - fixture.detectChanges(); + fixture.componentInstance.disabledFlag = false; + fixture.detectChanges(); - await expectAsync(dropdownHarness.getButtonStyle()).toBeResolvedTo( - 'default', - ); - }); + await expectAsync(dropdownHarness.isDisabled()).toBeResolvedTo(false); + }); - it('should get the button style', async () => { - const { dropdownHarness, fixture } = await setupTest(); - const styles = ['default', 'primary', 'link']; + it('should get the aria-label', async () => { + const { dropdownHarness, fixture } = await setupTest(dataSkyId); - for (const style of styles) { - fixture.componentInstance.buttonStyle = style; + fixture.componentInstance.ariaLabel = 'aria-label'; fixture.detectChanges(); - await expectAsync(dropdownHarness.getButtonStyle()).toBeResolvedTo(style); - } - }); - - it('should get default button type', async () => { - const { dropdownHarness, fixture } = await setupTest(); + await expectAsync(dropdownHarness.getAriaLabel()).toBeResolvedTo( + 'aria-label', + ); + }); - fixture.detectChanges(); + it('should have no tooltip if undefined', async () => { + const { dropdownHarness, fixture } = await setupTest(dataSkyId); - await expectAsync(dropdownHarness.getButtonType()).toBeResolvedTo('select'); - }); + fixture.detectChanges(); - it('should get the button type', async () => { - const { dropdownHarness, fixture } = await setupTest(); + await expectAsync(dropdownHarness.getTitle()).toBeResolvedTo(null); + }); - const types = ['select', 'tab', 'context-menu']; + it('should get the tooltip title', async () => { + const { dropdownHarness, fixture } = await setupTest(); - for (const type of types) { - fixture.componentInstance.buttonType = type; + fixture.componentInstance.tooltipTitle = 'dropdown demo'; fixture.detectChanges(); - await expectAsync(dropdownHarness.getButtonType()).toBeResolvedTo(type); - } - }); + await expectAsync(dropdownHarness.getTitle()).toBeResolvedTo( + 'dropdown demo', + ); + }); - it('should get the default value for disabled button', async () => { - const { dropdownHarness, fixture } = await setupTest(); + it('should get the correct value if dropdown menu is open or not', async () => { + const { dropdownHarness, fixture } = await setupTest(dataSkyId); - fixture.detectChanges(); + fixture.detectChanges(); - await expectAsync(dropdownHarness.isDisabled()).toBeResolvedTo(false); - }); + await expectAsync(dropdownHarness.isOpen()).toBeResolvedTo(false); - it('should get the correct value for disabled button', async () => { - const { dropdownHarness, fixture } = await setupTest(); + await dropdownHarness.clickDropdownButton(); + fixture.detectChanges(); - fixture.componentInstance.disabledFlag = true; - fixture.detectChanges(); + await expectAsync(dropdownHarness.isOpen()).toBeResolvedTo(true); - await expectAsync(dropdownHarness.isDisabled()).toBeResolvedTo(true); + await dropdownHarness.clickDropdownButton(); + fixture.detectChanges(); - fixture.componentInstance.disabledFlag = false; - fixture.detectChanges(); + await expectAsync(dropdownHarness.isOpen()).toBeResolvedTo(false); + }); - await expectAsync(dropdownHarness.isDisabled()).toBeResolvedTo(false); - }); + it('should close the dropdown menu if clicking out', async () => { + const { dropdownHarness, fixture } = await setupTest(dataSkyId); - it('should have default aria-label for context menus', async () => { - const { dropdownHarness, fixture } = await setupTest(); - fixture.componentInstance.buttonType = 'context-menu'; + fixture.detectChanges(); + await dropdownHarness.clickDropdownButton(); + fixture.detectChanges(); + await (await dropdownHarness.getDropdownMenu()).clickOut(); - fixture.detectChanges(); + await expectAsync(dropdownHarness.isOpen()).toBeResolvedTo(false); + }); - await expectAsync(dropdownHarness.getAriaLabel()).toBeResolvedTo( - 'Context menu', - ); - }); + it('should get the dropdown menu harness', async () => { + const { dropdownHarness, fixture } = await setupTest(dataSkyId); - it('should get the aria-label', async () => { - const { dropdownHarness, fixture } = await setupTest(); + fixture.componentInstance.menuRole = 'dropdown-menu'; + fixture.detectChanges(); + await dropdownHarness.clickDropdownButton(); + fixture.detectChanges(); - fixture.componentInstance.ariaLabel = 'aria-label'; - fixture.detectChanges(); + const dropdownMenuHarness = await dropdownHarness.getDropdownMenu(); - await expectAsync(dropdownHarness.getAriaLabel()).toBeResolvedTo( - 'aria-label', - ); - }); + await expectAsync(dropdownMenuHarness.getAriaRole()).toBeResolvedTo( + 'dropdown-menu', + ); + }); + } - it('should have no tooltip if undefined', async () => { - const { dropdownHarness, fixture } = await setupTest(); + describe('with default trigger button', () => { + it('should get the dropdown from its data-sky-id', async () => { + const { dropdownHarness, fixture } = await setupTest('other-dropdown'); - fixture.detectChanges(); + fixture.detectChanges(); - await expectAsync(dropdownHarness.getTitle()).toBeResolvedTo(null); - }); + await expectAsync(dropdownHarness.getButtonStyle()).toBeResolvedTo( + 'primary', + ); + }); - it('should get the tooltip title', async () => { - const { dropdownHarness, fixture } = await setupTest(); + it('should get the default button style', async () => { + const { dropdownHarness, fixture } = await setupTest(); - fixture.componentInstance.tooltipTitle = 'dropdown demo'; - fixture.detectChanges(); + fixture.detectChanges(); - await expectAsync(dropdownHarness.getTitle()).toBeResolvedTo( - 'dropdown demo', - ); - }); + await expectAsync(dropdownHarness.getButtonStyle()).toBeResolvedTo( + 'default', + ); + }); - it('should get the correct value if dropdown menu is open or not', async () => { - const { dropdownHarness, fixture } = await setupTest(); + it('should get the button style', async () => { + const { dropdownHarness, fixture } = await setupTest(); + const styles = ['default', 'primary', 'link']; - fixture.detectChanges(); + for (const style of styles) { + fixture.componentInstance.buttonStyle = style; + fixture.detectChanges(); - await expectAsync(dropdownHarness.isOpen()).toBeResolvedTo(false); + await expectAsync(dropdownHarness.getButtonStyle()).toBeResolvedTo( + style, + ); + } + }); - await dropdownHarness.clickDropdownButton(); - fixture.detectChanges(); + it('should get default button type', async () => { + const { dropdownHarness, fixture } = await setupTest(); - await expectAsync(dropdownHarness.isOpen()).toBeResolvedTo(true); + fixture.detectChanges(); - await dropdownHarness.clickDropdownButton(); - fixture.detectChanges(); + await expectAsync(dropdownHarness.getButtonType()).toBeResolvedTo( + 'select', + ); + }); - await expectAsync(dropdownHarness.isOpen()).toBeResolvedTo(false); - }); + it('should get the button type', async () => { + const { dropdownHarness, fixture } = await setupTest(); - it('should close the dropdown menu if clicking out', async () => { - const { dropdownHarness, fixture } = await setupTest(); + const types = ['select', 'tab', 'context-menu']; - fixture.detectChanges(); - await dropdownHarness.clickDropdownButton(); - fixture.detectChanges(); - await (await dropdownHarness.getDropdownMenu()).clickOut(); + for (const type of types) { + fixture.componentInstance.buttonType = type; + fixture.detectChanges(); - await expectAsync(dropdownHarness.isOpen()).toBeResolvedTo(false); - }); + await expectAsync(dropdownHarness.getButtonType()).toBeResolvedTo(type); + } + }); - it('should get the dropdown menu harness', async () => { - const { dropdownHarness, fixture } = await setupTest(); + it('should have default aria-label for context menus', async () => { + const { dropdownHarness, fixture } = await setupTest(); + fixture.componentInstance.buttonType = 'context-menu'; - fixture.componentInstance.menuRole = 'dropdown-menu'; - fixture.detectChanges(); - await dropdownHarness.clickDropdownButton(); - fixture.detectChanges(); + fixture.detectChanges(); - const dropdownMenuHarness = await dropdownHarness.getDropdownMenu(); + await expectAsync(dropdownHarness.getAriaLabel()).toBeResolvedTo( + 'Context menu', + ); + }); + + runTriggerTests(); + }); - await expectAsync(dropdownMenuHarness.getAriaRole()).toBeResolvedTo( - 'dropdown-menu', - ); + describe('with custom trigger button', () => { + runTriggerTests('custom-trigger'); }); describe('Dropdown menu test harness', () => { diff --git a/libs/components/popovers/testing/src/modules/dropdown/dropdown-harness.ts b/libs/components/popovers/testing/src/modules/dropdown/dropdown-harness.ts index bca1a46b7c..152aaccadd 100644 --- a/libs/components/popovers/testing/src/modules/dropdown/dropdown-harness.ts +++ b/libs/components/popovers/testing/src/modules/dropdown/dropdown-harness.ts @@ -12,7 +12,12 @@ export class SkyDropdownHarness extends SkyComponentHarness { #documentRootLocator = this.documentRootLocatorFactory(); - #getDropdownButton = this.locatorFor('.sky-dropdown-button'); + #getDropdownButton = this.locatorFor( + // Default trigger button + '.sky-dropdown-button', + // Custom trigger button + '[skyDropdownTrigger]', + ); /** * Gets a `HarnessPredicate` that can be used to search for a From 0d47f06fbb2ba57652fdfa0e1ab0e9fbfd22453c Mon Sep 17 00:00:00 2001 From: Blackbaud Sky Build User Date: Wed, 12 Feb 2025 11:50:22 -0500 Subject: [PATCH 18/20] =?UTF-8?q?chore:=20changelog=20for=2011.41.0=20(#31?= =?UTF-8?q?39)=20=F0=9F=8D=92=20(#3144)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7352935620..6af2258e7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [11.41.0](https://github.com/blackbaud/skyux/compare/11.40.0...11.41.0) (2025-02-11) + + +### Features + +* **components/core:** add SkyViewkeeper support for SkyAppViewportService properties ([#3120](https://github.com/blackbaud/skyux/issues/3120)) ([f994ff7](https://github.com/blackbaud/skyux/commit/f994ff75cb27c00a5cfdadad5c55aebabb001f8c)) + + +### Bug Fixes + +* **components/datetime:** address flaky display behavior of key date tooltips ([#3128](https://github.com/blackbaud/skyux/issues/3128)) ([bd94278](https://github.com/blackbaud/skyux/commit/bd942789232b3affeee8303190ee234aee7d27ef)) + ## [11.40.0](https://github.com/blackbaud/skyux/compare/11.39.0...11.40.0) (2025-02-10) From fb9557fe8fdafdab793976fe2bb5f8c255d36f34 Mon Sep 17 00:00:00 2001 From: Blackbaud Sky Build User Date: Wed, 12 Feb 2025 16:22:18 -0500 Subject: [PATCH 19/20] ci: workflow to keep automated-translations in sync (#3145) (#3148) :cherries: Cherry picked from #3145 [ci: workflow to keep automated-translations in sync](https://github.com/blackbaud/skyux/pull/3145) [AB#3256198](https://dev.azure.com/blackbaud/f565481a-7bc9-4083-95d5-4f953da6d499/_workitems/edit/3256198) Co-authored-by: John White <750350+johnhwhite@users.noreply.github.com> --- .github/workflows/automated-translations.yml | 86 ++++++++++ scripts/automated-translations.ps1 | 172 +++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 .github/workflows/automated-translations.yml create mode 100755 scripts/automated-translations.ps1 diff --git a/.github/workflows/automated-translations.yml b/.github/workflows/automated-translations.yml new file mode 100644 index 0000000000..0f599d6280 --- /dev/null +++ b/.github/workflows/automated-translations.yml @@ -0,0 +1,86 @@ +# Developers commit changes to the LTS branch. This workflow keeps the `automated-translations` branch up to date +# with the LTS branch. +# +# Lingoport will watch the automated-translations branch and automatically translate the resources_en_US.json +# files to the target languages. The translated files will be committed back to the automated-translations branch. +# +# When the `automated-translations` branch has changes to merge back, this workflow creates a pull request. +# +# When the `automated-translations` branch is merged and deleted, this workflow recreates the branch. + +name: Automated Translations 11.x.x +on: + push: + branches: + - 11.x.x + - automated-translations + workflow_dispatch: +env: + LTS_BRANCH: '11.x.x' +jobs: + automated-translations: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + token: '${{ secrets.GH_PERSONAL_ACCESS_TOKEN }}' + - uses: actions/setup-node@v4 + id: setup-node + with: + node-version-file: '.nvmrc' + - name: Ensure cache directory exists + run: mkdir -p /home/runner/.npm + continue-on-error: true + - name: Sync Translation Branch + id: sync + run: | + ./scripts/automated-translations.ps1 \ + -LtsBranchName $LTS_BRANCH \ + -TempPath ${{ runner.temp }} + env: + GH_TOKEN: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} + + - name: Notify Slack when a PR is created + if: ${{ steps.sync.outputs.prCreated == 'true' && steps.sync.outputs.prTitle && steps.sync.outputs.prUrl }} + uses: rtCamp/action-slack-notify@v2 + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + SLACK_TITLE: ':writing_hand: `automated-translations` PR created: ${{ steps.sync.outputs.prTitle }}' + SLACK_MESSAGE: '${{ steps.sync.outputs.prUrl }}' + SLACK_ICON_EMOJI: ':github:' + SLACK_USERNAME: GitHub + #cor-skyux-notifications + SLACK_CHANNEL: C01GY7ZP4HM + SLACK_COLOR: ${{ job.status }} + SLACK_FOOTER: 'Blackbaud Sky Build User' + MSG_MINIMAL: 'true' + + - name: Notify Slack when a PR is updated + if: ${{ steps.sync.outputs.prCreated == 'false' && steps.sync.outputs.prTitle && steps.sync.outputs.prUrl }} + uses: rtCamp/action-slack-notify@v2 + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + SLACK_TITLE: ':writing_hand: `automated-translations` PR updated: ${{ steps.sync.outputs.prTitle }}' + SLACK_MESSAGE: '${{ steps.sync.outputs.prUrl }}' + SLACK_ICON_EMOJI: ':github:' + SLACK_USERNAME: GitHub + #cor-skyux-notifications + SLACK_CHANNEL: C01GY7ZP4HM + SLACK_COLOR: ${{ job.status }} + SLACK_FOOTER: 'Blackbaud Sky Build User' + MSG_MINIMAL: 'true' + + - name: Notify Slack for fails + if: ${{ failure() }} + uses: rtCamp/action-slack-notify@v2 + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + SLACK_TITLE: ':writing_hand: :x: `automated-translations` sync failed' + SLACK_MESSAGE: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + SLACK_ICON_EMOJI: ':github:' + SLACK_USERNAME: GitHub + #cor-skyux-notifications + SLACK_CHANNEL: C01GY7ZP4HM + SLACK_COLOR: 'fail' + SLACK_FOOTER: 'Blackbaud Sky Build User' + MSG_MINIMAL: 'true' diff --git a/scripts/automated-translations.ps1 b/scripts/automated-translations.ps1 new file mode 100755 index 0000000000..1eeeed5316 --- /dev/null +++ b/scripts/automated-translations.ps1 @@ -0,0 +1,172 @@ +#!/usr/bin/env pwsh + +[CmdletBinding()] +param ( + [string]$LtsBranchName, + [string]$TempPath, + [string]$IsDryRun="false" +) + +if (-not $LtsBranchName) +{ + Write-Output "`n::error::The LTS branch name is required.`n" + exit 1 +} + +if (-not $TempPath -or -not (Test-Path -Path $TempPath -PathType Container)) +{ + Write-Output "`n::error::The temp path is required.`n" + exit 1 +} + +$IsDryRunBool = [System.Convert]::ToBoolean("$IsDryRun") + +$CommitMessage = "chore: update library resources" +$GitUser = "user.name=blackbaud-sky-build-user" +$GitEmail = "user.email=sky-build-user@blackbaud.com" +$GitRepo = "blackbaud/skyux" +$TranslationBranchName = "automated-translations" +$WorkingCopy = "$TempPath/$TranslationBranchName" + +if (Test-Path -Path $WorkingCopy -PathType Container) +{ + Write-Output "`n::error::The path $WorkingCopy already exists.`n" + exit 1 +} + +# Sync the translation branch with the LTS branch +Write-Output "`n::group::Clone $LtsBranchName branch`n" +Write-Output "`n# gh repo clone $GitRepo $WorkingCopy --upstream-remote-name origin -- --branch $LtsBranchName" +gh repo clone $GitRepo $WorkingCopy --upstream-remote-name origin -- --branch $LtsBranchName +Write-Output "`n::endgroup::`n" + +Set-Location -Path $WorkingCopy +$remoteBranchExists = git ls-remote -b origin $TranslationBranchName + +if (-not $remoteBranchExists) +{ + Write-Output "`n::group::Create new $TranslationBranchName branch`n" + Write-Output "`n# git checkout -B $TranslationBranchName $LtsBranchName" + git checkout -B $TranslationBranchName $LtsBranchName + + if (-not $IsDryRunBool) + { + Write-Output "`n➡︎ The $TranslationBranchName branch does not exist. Creating the branch.`n" + Write-Output "`n# git push origin $TranslationBranchName" + git push origin $TranslationBranchName + } + Write-Output "`n::endgroup::`n" +} +else +{ + Write-Output "`n::group::Update $TranslationBranchName branch from $LtsBranchName branch`n" + Write-Output "`n# git checkout -B $TranslationBranchName origin/$TranslationBranchName" + git checkout -B $TranslationBranchName origin/$TranslationBranchName + Write-Output "`n# git pull" + git pull --set-upstream origin $TranslationBranchName + + if ($IsDryRunBool) + { + Write-Output "`n# git merge -X theirs --no-commit $LtsBranchName" + git merge -X theirs --no-commit $LtsBranchName + } + else + { + Write-Output "`n# git merge -X theirs $LtsBranchName" + git -c "$GitUser" -c "$GitEmail" merge -X theirs $LtsBranchName + } + Write-Output "`n::endgroup::`n" + + Write-Output "`n::group::NPM Install`n" + Write-Output "`n# npm ci" + npm ci --no-audit --no-progress --no-fund + Write-Output "`n::endgroup::`n" + + Write-Output "`n::group::Update library resources`n" + Write-Output "`n# npm run dev:create-library-resources" + npm run dev:create-library-resources + Write-Output "`n::endgroup::`n" + + Write-Output "`n::group::Prettier`n" + Write-Output "`n# npx nx format:write" + npx nx format:write + Write-Output "`n::endgroup::`n" + + Write-Output "`n::group::Check for changes`n" + Write-Output "`n# git status" + git status + Write-Output "#" + + $changes = git status --porcelain + + if ($changes) + { + if ($IsDryRunBool) + { + Write-Output "`nChanges detected. Run the script without the -IsDryRun flag to commit the changes.`n" + } + else + { + Write-Output "`n::endgroup::`n" + + Write-Output "`n::group::Push changes to $TranslationBranchName branch`n" + Write-Output "`n# git commit -am '${CommitMessage}'" + git add -A + git -c "$GitUser" -c "$GitEmail" commit -m "${CommitMessage}" + } + } + else + { + Write-Output "`n➡︎ No changes detected.`n" + } + + if (-not $IsDryRunBool) + { + Write-Output "`n# git push origin $TranslationBranchName" + git push origin $TranslationBranchName + } + else + { + Write-Output "`nDry run complete. Run the script without the -IsDryRun flag to push the changes.`n" + } + Write-Output "`n::endgroup::`n" + + $changesFromLts = git diff $LtsBranchName --name-only + if ($changesFromLts) + { + Write-Output "`n::group::Pull request`n" + $prForChanges = gh pr list --json title,url,headRefName --jq ".[] | select(.headRefName == `"${LtsBranchName}`")" + if ($prForChanges) + { + if ($env:GITHUB_OUTPUT) + { + Write-Output "prCreated=false" >> $env:GITHUB_OUTPUT + } + } + else + { + Write-Output "`n➡︎ Creating a pull request for changes" + Write-Output "`n# gh pr create --base $TranslationBranchName --head $LtsBranchName --title '${CommitMessage}'" + gh pr create --base $TranslationBranchName --head $LtsBranchName --title "${CommitMessage}" + $prForChanges = gh pr list --json title,url,headRefName --jq ".[] | select(.headRefName == `"${LtsBranchName}`")" + if ($env:GITHUB_OUTPUT) + { + Write-Output "prCreated=true" >> $env:GITHUB_OUTPUT + } + } + $pr = $prForChanges | ConvertFrom-Json + Write-Output "`n➡︎ Pull request for changes:`n $($pr.title)`n $($pr.url)`n" + if ($env:GITHUB_OUTPUT) + { + Write-Output "prTitle=$($pr.title)" >> $env:GITHUB_OUTPUT + Write-Output "prUrl=$($pr.url)" >> $env:GITHUB_OUTPUT + } + Write-Output "`n::endgroup::`n" + } + else + { + Write-Output "`n::group::No pull request`n" + Write-Output "`n➡︎ No changes to merge to $LtsBranchName branch from $TranslationBranchName branch.`n" + Write-Output "`n::endgroup::`n" + } +} From 02caf83be078812ae7158aeec6d42701f34e4248 Mon Sep 17 00:00:00 2001 From: Sandhya Adhirvuh Date: Wed, 12 Feb 2025 17:03:50 -0500 Subject: [PATCH 20/20] feat(components/toast): add toast and toaster harness (#3141) (#3147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🍒 cherry pick from [#3141](https://github.com/blackbaud/skyux/pull/3141) [AB#2195574](https://dev.azure.com/blackbaud/Products/_boards/board/t/SKY%20UX%20Program/Stories/?workitem=2195574) --------- Co-authored-by: Sandhya Adhirvuh --- .../toast/toast/basic/demo.component.spec.ts | 58 +++++++ .../toast/toast/basic/demo.component.ts | 4 +- .../custom-component/custom-toast-harness.ts | 9 ++ .../custom-component/demo.component.spec.ts | 56 +++++++ .../toast/custom-component/demo.component.ts | 4 +- .../toast/basic/example.component.spec.ts | 58 +++++++ .../toast/toast/basic/example.component.ts | 4 +- .../custom-component/custom-toast-harness.ts | 9 ++ .../example.component.spec.ts | 56 +++++++ .../custom-component/example.component.ts | 4 +- .../overlay/overlay-harness-filters.ts | 4 +- libs/components/toast/package.json | 1 + libs/components/toast/project.json | 2 +- .../lib/modules/toast/types/toast-config.ts | 2 +- .../src/modules/toast-harness-filters.ts | 12 ++ .../testing/src/modules/toast-harness.ts | 72 +++++++++ .../src/modules/toaster-harness.spec.ts | 145 ++++++++++++++++++ .../testing/src/modules/toaster-harness.ts | 32 ++++ .../toast/testing/src/public-api.ts | 4 + 19 files changed, 524 insertions(+), 12 deletions(-) create mode 100644 apps/code-examples/src/app/code-examples/toast/toast/basic/demo.component.spec.ts create mode 100644 apps/code-examples/src/app/code-examples/toast/toast/custom-component/custom-toast-harness.ts create mode 100644 apps/code-examples/src/app/code-examples/toast/toast/custom-component/demo.component.spec.ts create mode 100644 libs/components/code-examples/src/lib/modules/toast/toast/basic/example.component.spec.ts create mode 100644 libs/components/code-examples/src/lib/modules/toast/toast/custom-component/custom-toast-harness.ts create mode 100644 libs/components/code-examples/src/lib/modules/toast/toast/custom-component/example.component.spec.ts create mode 100644 libs/components/toast/testing/src/modules/toast-harness-filters.ts create mode 100644 libs/components/toast/testing/src/modules/toast-harness.ts create mode 100644 libs/components/toast/testing/src/modules/toaster-harness.spec.ts create mode 100644 libs/components/toast/testing/src/modules/toaster-harness.ts diff --git a/apps/code-examples/src/app/code-examples/toast/toast/basic/demo.component.spec.ts b/apps/code-examples/src/app/code-examples/toast/toast/basic/demo.component.spec.ts new file mode 100644 index 0000000000..ed4e83ae1c --- /dev/null +++ b/apps/code-examples/src/app/code-examples/toast/toast/basic/demo.component.spec.ts @@ -0,0 +1,58 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { SkyToastType } from '@skyux/toast'; +import { SkyToasterHarness } from '@skyux/toast/testing'; + +import { DemoComponent } from './demo.component'; + +describe('Basic toast demo', () => { + let fixture: ComponentFixture; + let loader: HarnessLoader; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [DemoComponent, NoopAnimationsModule], + }); + + fixture = TestBed.createComponent(DemoComponent); + loader = TestbedHarnessEnvironment.documentRootLoader(fixture); + }); + + async function setupTest(): Promise<{ + toasterHarness: SkyToasterHarness; + }> { + fixture.componentInstance.openToast(); + fixture.detectChanges(); + + const toasterHarness: SkyToasterHarness = + await loader.getHarness(SkyToasterHarness); + + return { toasterHarness }; + } + + it('should open success toasts', async () => { + const { toasterHarness } = await setupTest(); + + fixture.componentInstance.openToast(); + fixture.detectChanges(); + + await expectAsync(toasterHarness.getNumberOfToasts()).toBeResolvedTo(2); + + const toast = await toasterHarness.getToastByMessage( + 'This is a sample toast message.', + ); + + await expectAsync(toast.getType()).toBeResolvedTo(SkyToastType.Success); + }); + + it('should close all toasts', async () => { + fixture.componentInstance.openToast(); + fixture.detectChanges(); + fixture.componentInstance.closeAll(); + fixture.detectChanges(); + + await expectAsync(loader.getHarness(SkyToasterHarness)).toBeRejected(); + }); +}); diff --git a/apps/code-examples/src/app/code-examples/toast/toast/basic/demo.component.ts b/apps/code-examples/src/app/code-examples/toast/toast/basic/demo.component.ts index d17e0c3fec..7184da9fb2 100644 --- a/apps/code-examples/src/app/code-examples/toast/toast/basic/demo.component.ts +++ b/apps/code-examples/src/app/code-examples/toast/toast/basic/demo.component.ts @@ -9,13 +9,13 @@ import { SkyToastService, SkyToastType } from '@skyux/toast'; export class DemoComponent { readonly #toastSvc = inject(SkyToastService); - protected openToast(): void { + public openToast(): void { this.#toastSvc.openMessage('This is a sample toast message.', { type: SkyToastType.Success, }); } - protected closeAll(): void { + public closeAll(): void { this.#toastSvc.closeAll(); } } diff --git a/apps/code-examples/src/app/code-examples/toast/toast/custom-component/custom-toast-harness.ts b/apps/code-examples/src/app/code-examples/toast/toast/custom-component/custom-toast-harness.ts new file mode 100644 index 0000000000..ec8f77d624 --- /dev/null +++ b/apps/code-examples/src/app/code-examples/toast/toast/custom-component/custom-toast-harness.ts @@ -0,0 +1,9 @@ +import { ComponentHarness } from '@angular/cdk/testing'; + +export class CustomToastHarness extends ComponentHarness { + public static hostSelector = 'app-toast-content-demo'; + + public async getText(): Promise { + return await (await this.host()).text(); + } +} diff --git a/apps/code-examples/src/app/code-examples/toast/toast/custom-component/demo.component.spec.ts b/apps/code-examples/src/app/code-examples/toast/toast/custom-component/demo.component.spec.ts new file mode 100644 index 0000000000..571024ee22 --- /dev/null +++ b/apps/code-examples/src/app/code-examples/toast/toast/custom-component/demo.component.spec.ts @@ -0,0 +1,56 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { SkyToastType } from '@skyux/toast'; +import { SkyToasterHarness } from '@skyux/toast/testing'; + +import { CustomToastHarness } from './custom-toast-harness'; +import { DemoComponent } from './demo.component'; + +describe('Custom component toast demo', () => { + let fixture: ComponentFixture; + let loader: HarnessLoader; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [DemoComponent, NoopAnimationsModule], + }); + + fixture = TestBed.createComponent(DemoComponent); + loader = TestbedHarnessEnvironment.documentRootLoader(fixture); + }); + + async function setupTest(): Promise<{ + toasterHarness: SkyToasterHarness; + }> { + fixture.componentInstance.openToast(); + fixture.detectChanges(); + + const toasterHarness: SkyToasterHarness = + await loader.getHarness(SkyToasterHarness); + + return { toasterHarness }; + } + + it('should open custom component in toast', async () => { + const { toasterHarness } = await setupTest(); + + const toasts = await toasterHarness.getToasts(); + const customHarness = await toasts[0].queryHarness(CustomToastHarness); + + await expectAsync(toasts[0].getType()).toBeResolvedTo(SkyToastType.Success); + await expectAsync(customHarness.getText()).toBeResolvedTo( + 'Custom message: This toast has embedded a custom component for its content.', + ); + }); + + it('should close all toasts', async () => { + fixture.componentInstance.openToast(); + fixture.detectChanges(); + fixture.componentInstance.closeAll(); + fixture.detectChanges(); + + await expectAsync(loader.getHarness(SkyToasterHarness)).toBeRejected(); + }); +}); diff --git a/apps/code-examples/src/app/code-examples/toast/toast/custom-component/demo.component.ts b/apps/code-examples/src/app/code-examples/toast/toast/custom-component/demo.component.ts index 0c05c038d0..c37f24db1f 100644 --- a/apps/code-examples/src/app/code-examples/toast/toast/custom-component/demo.component.ts +++ b/apps/code-examples/src/app/code-examples/toast/toast/custom-component/demo.component.ts @@ -12,7 +12,7 @@ import { CustomToastComponent } from './custom-toast.component'; export class DemoComponent { readonly #toastSvc = inject(SkyToastService); - protected openToast(): void { + public openToast(): void { const context = new CustomToastContext( 'This toast has embedded a custom component for its content.', ); @@ -31,7 +31,7 @@ export class DemoComponent { ); } - protected closeAll(): void { + public closeAll(): void { this.#toastSvc.closeAll(); } } diff --git a/libs/components/code-examples/src/lib/modules/toast/toast/basic/example.component.spec.ts b/libs/components/code-examples/src/lib/modules/toast/toast/basic/example.component.spec.ts new file mode 100644 index 0000000000..7ca168fd04 --- /dev/null +++ b/libs/components/code-examples/src/lib/modules/toast/toast/basic/example.component.spec.ts @@ -0,0 +1,58 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { SkyToastType } from '@skyux/toast'; +import { SkyToasterHarness } from '@skyux/toast/testing'; + +import { ToastBasicExampleComponent } from './example.component'; + +describe('Basic toast example', () => { + let fixture: ComponentFixture; + let loader: HarnessLoader; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ToastBasicExampleComponent, NoopAnimationsModule], + }); + + fixture = TestBed.createComponent(ToastBasicExampleComponent); + loader = TestbedHarnessEnvironment.documentRootLoader(fixture); + }); + + async function setupTest(): Promise<{ + toasterHarness: SkyToasterHarness; + }> { + fixture.componentInstance.openToast(); + fixture.detectChanges(); + + const toasterHarness: SkyToasterHarness = + await loader.getHarness(SkyToasterHarness); + + return { toasterHarness }; + } + + it('should open success toasts', async () => { + const { toasterHarness } = await setupTest(); + + fixture.componentInstance.openToast(); + fixture.detectChanges(); + + await expectAsync(toasterHarness.getNumberOfToasts()).toBeResolvedTo(2); + + const toast = await toasterHarness.getToastByMessage( + 'This is a sample toast message.', + ); + + await expectAsync(toast.getType()).toBeResolvedTo(SkyToastType.Success); + }); + + it('should close all toasts', async () => { + fixture.componentInstance.openToast(); + fixture.detectChanges(); + fixture.componentInstance.closeAll(); + fixture.detectChanges(); + + await expectAsync(loader.getHarness(SkyToasterHarness)).toBeRejected(); + }); +}); diff --git a/libs/components/code-examples/src/lib/modules/toast/toast/basic/example.component.ts b/libs/components/code-examples/src/lib/modules/toast/toast/basic/example.component.ts index 56acc5bb34..8f2abf5788 100644 --- a/libs/components/code-examples/src/lib/modules/toast/toast/basic/example.component.ts +++ b/libs/components/code-examples/src/lib/modules/toast/toast/basic/example.component.ts @@ -9,13 +9,13 @@ import { SkyToastService, SkyToastType } from '@skyux/toast'; export class ToastBasicExampleComponent { readonly #toastSvc = inject(SkyToastService); - protected openToast(): void { + public openToast(): void { this.#toastSvc.openMessage('This is a sample toast message.', { type: SkyToastType.Success, }); } - protected closeAll(): void { + public closeAll(): void { this.#toastSvc.closeAll(); } } diff --git a/libs/components/code-examples/src/lib/modules/toast/toast/custom-component/custom-toast-harness.ts b/libs/components/code-examples/src/lib/modules/toast/toast/custom-component/custom-toast-harness.ts new file mode 100644 index 0000000000..187dbf28e5 --- /dev/null +++ b/libs/components/code-examples/src/lib/modules/toast/toast/custom-component/custom-toast-harness.ts @@ -0,0 +1,9 @@ +import { ComponentHarness } from '@angular/cdk/testing'; + +export class CustomToastHarness extends ComponentHarness { + public static hostSelector = 'app-toast-content-example'; + + public async getText(): Promise { + return await (await this.host()).text(); + } +} diff --git a/libs/components/code-examples/src/lib/modules/toast/toast/custom-component/example.component.spec.ts b/libs/components/code-examples/src/lib/modules/toast/toast/custom-component/example.component.spec.ts new file mode 100644 index 0000000000..7a13790287 --- /dev/null +++ b/libs/components/code-examples/src/lib/modules/toast/toast/custom-component/example.component.spec.ts @@ -0,0 +1,56 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { SkyToastType } from '@skyux/toast'; +import { SkyToasterHarness } from '@skyux/toast/testing'; + +import { CustomToastHarness } from './custom-toast-harness'; +import { ToastCustomComponentExampleComponent } from './example.component'; + +describe('Custom component toast demo', () => { + let fixture: ComponentFixture; + let loader: HarnessLoader; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ToastCustomComponentExampleComponent, NoopAnimationsModule], + }); + + fixture = TestBed.createComponent(ToastCustomComponentExampleComponent); + loader = TestbedHarnessEnvironment.documentRootLoader(fixture); + }); + + async function setupTest(): Promise<{ + toasterHarness: SkyToasterHarness; + }> { + fixture.componentInstance.openToast(); + fixture.detectChanges(); + + const toasterHarness: SkyToasterHarness = + await loader.getHarness(SkyToasterHarness); + + return { toasterHarness }; + } + + it('should open custom component in toast', async () => { + const { toasterHarness } = await setupTest(); + + const toasts = await toasterHarness.getToasts(); + const customHarness = await toasts[0].queryHarness(CustomToastHarness); + + await expectAsync(toasts[0].getType()).toBeResolvedTo(SkyToastType.Success); + await expectAsync(customHarness.getText()).toBeResolvedTo( + 'Custom message: This toast has embedded a custom component for its content.', + ); + }); + + it('should close all toasts', async () => { + fixture.componentInstance.openToast(); + fixture.detectChanges(); + fixture.componentInstance.closeAll(); + fixture.detectChanges(); + + await expectAsync(loader.getHarness(SkyToasterHarness)).toBeRejected(); + }); +}); diff --git a/libs/components/code-examples/src/lib/modules/toast/toast/custom-component/example.component.ts b/libs/components/code-examples/src/lib/modules/toast/toast/custom-component/example.component.ts index a9afc90841..c7a68ef8ff 100644 --- a/libs/components/code-examples/src/lib/modules/toast/toast/custom-component/example.component.ts +++ b/libs/components/code-examples/src/lib/modules/toast/toast/custom-component/example.component.ts @@ -12,7 +12,7 @@ import { CustomToastComponent } from './custom-toast.component'; export class ToastCustomComponentExampleComponent { readonly #toastSvc = inject(SkyToastService); - protected openToast(): void { + public openToast(): void { const context = new CustomToastContext( 'This toast has embedded a custom component for its content.', ); @@ -31,7 +31,7 @@ export class ToastCustomComponentExampleComponent { ); } - protected closeAll(): void { + public closeAll(): void { this.#toastSvc.closeAll(); } } diff --git a/libs/components/core/testing/src/modules/overlay/overlay-harness-filters.ts b/libs/components/core/testing/src/modules/overlay/overlay-harness-filters.ts index 7eb7d523b9..3931abfe90 100644 --- a/libs/components/core/testing/src/modules/overlay/overlay-harness-filters.ts +++ b/libs/components/core/testing/src/modules/overlay/overlay-harness-filters.ts @@ -1,8 +1,8 @@ -import { SkyHarnessFilters } from '../../shared/harness-filters'; +import { BaseHarnessFilters } from '@angular/cdk/testing'; /** * A set of criteria that can be used to filter a list of SkyOverlayHarness instances. * @internal */ // eslint-disable-next-line @typescript-eslint/no-empty-interface, @typescript-eslint/no-empty-object-type -export interface SkyOverlayHarnessFilters extends SkyHarnessFilters {} +export interface SkyOverlayHarnessFilters extends BaseHarnessFilters {} diff --git a/libs/components/toast/package.json b/libs/components/toast/package.json index 56bc56e693..7b8659b7db 100644 --- a/libs/components/toast/package.json +++ b/libs/components/toast/package.json @@ -17,6 +17,7 @@ "homepage": "https://github.com/blackbaud/skyux#readme", "peerDependencies": { "@angular/animations": "^19.0.5", + "@angular/cdk": "^19.0.4", "@angular/common": "^19.0.5", "@angular/core": "^19.0.5", "@angular/platform-browser": "^19.0.5", diff --git a/libs/components/toast/project.json b/libs/components/toast/project.json index 072faa208f..ef174553c7 100644 --- a/libs/components/toast/project.json +++ b/libs/components/toast/project.json @@ -24,7 +24,7 @@ "dependsOn": [ "^build", { - "projects": ["testing"], + "projects": ["core", "testing"], "target": "build" } ], diff --git a/libs/components/toast/src/lib/modules/toast/types/toast-config.ts b/libs/components/toast/src/lib/modules/toast/types/toast-config.ts index 35a526a324..49a4b053e3 100644 --- a/libs/components/toast/src/lib/modules/toast/types/toast-config.ts +++ b/libs/components/toast/src/lib/modules/toast/types/toast-config.ts @@ -8,7 +8,7 @@ import { SkyToastType } from './toast-type'; */ export interface SkyToastConfig { /** - * The `SkyToastType` type that determines the color and icon for the toast. This property defaults to `SkyToastType.Info`. + * The `SkyToastType` type that determines the color and icon for the toast. This property defaults to `Info`. */ type?: SkyToastType; diff --git a/libs/components/toast/testing/src/modules/toast-harness-filters.ts b/libs/components/toast/testing/src/modules/toast-harness-filters.ts new file mode 100644 index 0000000000..942bc258d0 --- /dev/null +++ b/libs/components/toast/testing/src/modules/toast-harness-filters.ts @@ -0,0 +1,12 @@ +import { BaseHarnessFilters } from '@angular/cdk/testing'; + +/** + * A set of criteria that can be used to filter a list of `SkyToastHarness` instances. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface, @typescript-eslint/no-empty-object-type +export interface SkyToastHarnessFilters extends BaseHarnessFilters { + /** + * Finds toasts with the matching text. + */ + message?: string; +} diff --git a/libs/components/toast/testing/src/modules/toast-harness.ts b/libs/components/toast/testing/src/modules/toast-harness.ts new file mode 100644 index 0000000000..2625567fb6 --- /dev/null +++ b/libs/components/toast/testing/src/modules/toast-harness.ts @@ -0,0 +1,72 @@ +import { HarnessPredicate } from '@angular/cdk/testing'; +import { SkyQueryableComponentHarness } from '@skyux/core/testing'; +import { SkyToastType } from '@skyux/toast'; + +import { SkyToastHarnessFilters } from './toast-harness-filters'; + +/** + * Harness for interacting with the toast component in tests. + */ +export class SkyToastHarness extends SkyQueryableComponentHarness { + /** + * @internal + */ + public static hostSelector = 'sky-toast'; + + #getToast = this.locatorFor('.sky-toast'); + + /** + * Gets a `HarnessPredicate` that can be used to search for a + * `SkyToastHarness` that meets certain criteria. + */ + public static with( + filters: SkyToastHarnessFilters, + ): HarnessPredicate { + return new HarnessPredicate(SkyToastHarness, filters).addOption( + 'message', + filters.message, + async (harness, message) => { + const harnessMessage = await harness.getMessage(); + return await HarnessPredicate.stringMatches(message, harnessMessage); + }, + ); + } + + /** + * Clicks the toast close button. + */ + public async close(): Promise { + const button = await this.locatorFor('.sky-toast-btn-close')(); + return await button.click(); + } + + /** + * Gets the toast message. + */ + public async getMessage(): Promise { + const toastBody = await this.locatorForOptional('.sky-toast-body')(); + + if (toastBody) { + return (await toastBody.text()).trim(); + } else { + throw new Error( + 'No toast message found. This method cannot be used to query toasts with custom components.', + ); + } + } + + /** + * Gets the toast type. + */ + public async getType(): Promise { + const toast = await this.#getToast(); + if (await toast.hasClass('sky-toast-danger')) { + return SkyToastType.Danger; + } else if (await toast.hasClass('sky-toast-warning')) { + return SkyToastType.Warning; + } else if (await toast.hasClass('sky-toast-success')) { + return SkyToastType.Success; + } + return SkyToastType.Info; + } +} diff --git a/libs/components/toast/testing/src/modules/toaster-harness.spec.ts b/libs/components/toast/testing/src/modules/toaster-harness.spec.ts new file mode 100644 index 0000000000..94c8309249 --- /dev/null +++ b/libs/components/toast/testing/src/modules/toaster-harness.spec.ts @@ -0,0 +1,145 @@ +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { Component, inject } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { + SkyToastInstance, + SkyToastModule, + SkyToastService, + SkyToastType, +} from '@skyux/toast'; + +import { SkyToasterHarness } from './toaster-harness'; + +@Component({ + standalone: true, + imports: [SkyToastModule], + template: ``, +}) +class TestComponent { + public toastSvc = inject(SkyToastService); + + public openToast(message: string, type: SkyToastType): void { + this.toastSvc.openMessage(message, { type: type }); + } +} + +@Component({ + standalone: true, + template: `
This is a custom component
`, +}) +class CustomComponent { + readonly #instance = inject(SkyToastInstance); + + protected close(): void { + this.#instance.close(); + } +} + +describe('Toast harness', () => { + async function setupTest(): Promise<{ + harness: SkyToasterHarness; + fixture: ComponentFixture; + }> { + await TestBed.configureTestingModule({ + imports: [TestComponent, CustomComponent, NoopAnimationsModule], + }).compileComponents(); + + const fixture = TestBed.createComponent(TestComponent); + const loader = TestbedHarnessEnvironment.documentRootLoader(fixture); + + fixture.componentInstance.openToast('toast message', SkyToastType.Info); + + const harness: SkyToasterHarness = + await loader.getHarness(SkyToasterHarness); + + return { harness, fixture }; + } + + it('should get toasts', async () => { + const { harness } = await setupTest(); + + const toasts = await harness.getToasts(); + + expect(toasts.length).toBe(1); + }); + + it('should get toast by message', async () => { + const { harness, fixture } = await setupTest(); + + fixture.componentInstance.openToast('other message', SkyToastType.Danger); + fixture.detectChanges(); + + const toast = await harness.getToastByMessage('other message'); + + await expectAsync(toast.getType()).toBeResolvedTo(SkyToastType.Danger); + }); + + describe('toast harness', () => { + it('should close the toasts', async () => { + const { harness, fixture } = await setupTest(); + + fixture.componentInstance.openToast('other message', SkyToastType.Danger); + fixture.detectChanges(); + + await expectAsync(harness.getNumberOfToasts()).toBeResolvedTo(2); + + const toasts = await harness.getToasts(); + await toasts[0].close(); + + await expectAsync(harness.getNumberOfToasts()).toBeResolvedTo(1); + }); + + it('should get the toast message', async () => { + const { harness } = await setupTest(); + + const toasts = await harness.getToasts(); + + await expectAsync(toasts[0].getMessage()).toBeResolvedTo('toast message'); + }); + + it('should get the toast type', async () => { + const { harness, fixture } = await setupTest(); + + fixture.componentInstance.openToast('danger toast', SkyToastType.Danger); + fixture.detectChanges(); + fixture.componentInstance.openToast( + 'warning toast', + SkyToastType.Warning, + ); + fixture.detectChanges(); + fixture.componentInstance.openToast( + 'success toast', + SkyToastType.Success, + ); + fixture.detectChanges(); + + const toasts = await harness.getToasts(); + + await expectAsync(toasts[0].getType()).toBeResolvedTo(SkyToastType.Info); + await expectAsync(toasts[1].getType()).toBeResolvedTo( + SkyToastType.Danger, + ); + await expectAsync(toasts[2].getType()).toBeResolvedTo( + SkyToastType.Warning, + ); + await expectAsync(toasts[3].getType()).toBeResolvedTo( + SkyToastType.Success, + ); + }); + + describe('custom toast component', () => { + it('should not get message for a custom component', async () => { + const { harness, fixture } = await setupTest(); + fixture.componentInstance.toastSvc.openComponent(CustomComponent); + fixture.detectChanges(); + + await expectAsync( + harness.getToastByMessage('This is a custom component'), + ).toBeRejectedWithError( + 'No toast message found. This method cannot be used to query toasts with custom components.', + ); + }); + }); + }); +}); diff --git a/libs/components/toast/testing/src/modules/toaster-harness.ts b/libs/components/toast/testing/src/modules/toaster-harness.ts new file mode 100644 index 0000000000..242c186bbe --- /dev/null +++ b/libs/components/toast/testing/src/modules/toaster-harness.ts @@ -0,0 +1,32 @@ +import { ComponentHarness } from '@angular/cdk/testing'; + +import { SkyToastHarness } from './toast-harness'; + +/** + * Harness for interacting with the toast container component in tests. + */ +export class SkyToasterHarness extends ComponentHarness { + /** + * @internal + */ + public static hostSelector = 'sky-toaster'; + + public async getNumberOfToasts(): Promise { + const toasts = await this.getToasts(); + return toasts.length; + } + + /** + * Gets all open toast harnesses. + */ + public async getToasts(): Promise { + return await this.locatorForAll(SkyToastHarness)(); + } + + /** + * Gets a toast harness that matches the `message`. + */ + public async getToastByMessage(message: string): Promise { + return await this.locatorFor(SkyToastHarness.with({ message: message }))(); + } +} diff --git a/libs/components/toast/testing/src/public-api.ts b/libs/components/toast/testing/src/public-api.ts index 757dc6d812..816a9602ed 100644 --- a/libs/components/toast/testing/src/public-api.ts +++ b/libs/components/toast/testing/src/public-api.ts @@ -1 +1,5 @@ export { SkyToastFixture } from './legacy/toast-fixture'; + +export { SkyToasterHarness } from './modules/toaster-harness'; +export { SkyToastHarness } from './modules/toast-harness'; +export { SkyToastHarnessFilters } from './modules/toast-harness-filters';