diff --git a/.vscode/settings.json b/.vscode/settings.json index 901c0d885..0eb138d69 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,11 +2,5 @@ { // Columns at which to show vertical rulers "editor.rulers": [100], - - // Controls after how many characters the editor will wrap to the next line. Setting this to 0 turns on viewport width wrapping (word wrapping). Setting this to -1 forces the editor to never wrap. - "editor.wordWrap": "wordWrapColumn", - "editor.wordWrapColumn": 100, - - "typescript.tsdk": "node_modules/typescript/lib", - "vsicons.presets.angular": true + "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/config/karma/local.karma.conf.js b/config/karma/local.karma.conf.js index 38740483f..9f44ac4ad 100644 --- a/config/karma/local.karma.conf.js +++ b/config/karma/local.karma.conf.js @@ -8,9 +8,7 @@ module.exports = function (config) { require('./shared.karma.conf')(config); config.set({ - browsers: [ - 'Chrome' - ] + browsers: ['Chrome'], + logLevel: config.LOG_DEBUG }); - }; diff --git a/skyux-spa-visual-tests/screenshots-baseline/popover-All-chrome-1000x800-dpr-1.png b/skyux-spa-visual-tests/screenshots-baseline/popover-All-chrome-1000x800-dpr-1.png deleted file mode 100644 index 0edffde44..000000000 Binary files a/skyux-spa-visual-tests/screenshots-baseline/popover-All-chrome-1000x800-dpr-1.png and /dev/null differ diff --git a/skyux-spa-visual-tests/screenshots-baseline/popover-Placement-Above-chrome-1000x800-dpr-1.png b/skyux-spa-visual-tests/screenshots-baseline/popover-Placement-Above-chrome-1000x800-dpr-1.png deleted file mode 100644 index 0545459dc..000000000 Binary files a/skyux-spa-visual-tests/screenshots-baseline/popover-Placement-Above-chrome-1000x800-dpr-1.png and /dev/null differ diff --git a/skyux-spa-visual-tests/screenshots-baseline/popover-Placement-Above-chrome-767x800-dpr-1.png b/skyux-spa-visual-tests/screenshots-baseline/popover-Placement-Above-chrome-767x800-dpr-1.png deleted file mode 100644 index d940caf1e..000000000 Binary files a/skyux-spa-visual-tests/screenshots-baseline/popover-Placement-Above-chrome-767x800-dpr-1.png and /dev/null differ diff --git a/skyux-spa-visual-tests/screenshots-baseline/popover-Placement-Below-chrome-1000x800-dpr-1.png b/skyux-spa-visual-tests/screenshots-baseline/popover-Placement-Below-chrome-1000x800-dpr-1.png deleted file mode 100644 index 8deb03c67..000000000 Binary files a/skyux-spa-visual-tests/screenshots-baseline/popover-Placement-Below-chrome-1000x800-dpr-1.png and /dev/null differ diff --git a/skyux-spa-visual-tests/screenshots-baseline/popover-Placement-Left-chrome-1000x800-dpr-1.png b/skyux-spa-visual-tests/screenshots-baseline/popover-Placement-Left-chrome-1000x800-dpr-1.png deleted file mode 100644 index 68593ba37..000000000 Binary files a/skyux-spa-visual-tests/screenshots-baseline/popover-Placement-Left-chrome-1000x800-dpr-1.png and /dev/null differ diff --git a/skyux-spa-visual-tests/screenshots-baseline/popover-Placement-Right-chrome-1000x800-dpr-1.png b/skyux-spa-visual-tests/screenshots-baseline/popover-Placement-Right-chrome-1000x800-dpr-1.png deleted file mode 100644 index 63c8609f0..000000000 Binary files a/skyux-spa-visual-tests/screenshots-baseline/popover-Placement-Right-chrome-1000x800-dpr-1.png and /dev/null differ diff --git a/skyux-spa-visual-tests/screenshots-baseline/popover-With-Dropdown-chrome-1000x800-dpr-1.png b/skyux-spa-visual-tests/screenshots-baseline/popover-With-Dropdown-chrome-1000x800-dpr-1.png deleted file mode 100644 index 32059377a..000000000 Binary files a/skyux-spa-visual-tests/screenshots-baseline/popover-With-Dropdown-chrome-1000x800-dpr-1.png and /dev/null differ diff --git a/skyux-spa-visual-tests/src/app/popover/popover-visual.component.html b/skyux-spa-visual-tests/src/app/popover/popover-visual.component.html index fe166149d..2fa98f902 100644 --- a/skyux-spa-visual-tests/src/app/popover/popover-visual.component.html +++ b/skyux-spa-visual-tests/src/app/popover/popover-visual.component.html @@ -71,6 +71,24 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed elit arcu. + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed elit arcu. + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed elit arcu. + + @@ -79,6 +97,24 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed elit arcu. + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed elit arcu. + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed elit arcu. + + @@ -88,6 +124,16 @@ +
+ + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed elit arcu. + +
+
+ +
+
+ + + + The content of a popover can be text, HTML, or Angular components. + + + + + + The content of a popover can be text, HTML, or Angular components. + +
+
+ +
+

+ +

+

+ +

+

+ +

+

+ +

+

+ +

+

+ +

+
diff --git a/skyux-spa-visual-tests/src/app/popover/popover-visual.component.scss b/skyux-spa-visual-tests/src/app/popover/popover-visual.component.scss index 0fc76fc54..22943b9fe 100644 --- a/skyux-spa-visual-tests/src/app/popover/popover-visual.component.scss +++ b/skyux-spa-visual-tests/src/app/popover/popover-visual.component.scss @@ -9,17 +9,23 @@ .popover-visual-container { padding: 10px; - } - /deep/ - .sky-popover-container { - position: static !important; - opacity: 1 !important; - visibility: visible !important; + ::ng-deep .sky-popover-container { + position: relative !important; + width: 100% !important; + opacity: 1 !important; + visibility: visible !important; + z-index: 1; + + .sky-popover { + margin: 0; + } + } } } #screenshot-popover-placements, +#screenshot-popover-tiny, #screenshot-popover-with-dropdown { padding: 200px 350px; } diff --git a/skyux-spa-visual-tests/src/app/popover/popover.visual-spec.ts b/skyux-spa-visual-tests/src/app/popover/popover.visual-spec.ts index 60a7bc2d9..628c45bef 100644 --- a/skyux-spa-visual-tests/src/app/popover/popover.visual-spec.ts +++ b/skyux-spa-visual-tests/src/app/popover/popover.visual-spec.ts @@ -34,6 +34,30 @@ describe('Popover', () => { .then(() => testPopoverPlacement('above')); }); + it('should open a popover above-left the caller', () => { + return SkyVisualTest + .setupTest('popover') + .then(() => testPopoverPlacement('above-left')); + }); + + it('should open a popover above-right the caller', () => { + return SkyVisualTest + .setupTest('popover') + .then(() => testPopoverPlacement('above-right')); + }); + + it('should open a popover below-left the caller', () => { + return SkyVisualTest + .setupTest('popover') + .then(() => testPopoverPlacement('below-left')); + }); + + it('should open a popover below-right the caller', () => { + return SkyVisualTest + .setupTest('popover') + .then(() => testPopoverPlacement('below-right')); + }); + it('should open a popover below the caller', () => { return SkyVisualTest .setupTest('popover') @@ -54,8 +78,16 @@ describe('Popover', () => { it('should handle tiny screens', () => { return SkyVisualTest - .setupTest('popover', 767) - .then(() => testPopoverPlacement('above')); + .setupTest('popover', 768) + .then(() => { + SkyVisualTest.scrollElementIntoView(`#screenshot-popover-tiny`); + element(by.id(`btn-popover-tiny`)).click(); + return SkyVisualTest + .compareScreenshot({ + screenshotName: `popover-placement-tiny`, + selector: '#screenshot-popover-tiny' + }); + }); }); it('should handle absolutely positioned items inside the popover', () => { @@ -72,4 +104,32 @@ describe('Popover', () => { }); }); }); + + it('should handle left aligned popover in positioned parent', () => { + return SkyVisualTest + .setupTest('popover') + .then(() => { + SkyVisualTest.scrollElementIntoView('#screenshot-popover-positioned-parent'); + element(by.id('btn-popover-position-parent-left')).click(); + return SkyVisualTest + .compareScreenshot({ + screenshotName: 'popover-position-parent-left', + selector: '#screenshot-popover-positioned-parent' + }); + }); + }); + + it('should handle right aligned popover in positioned parent', () => { + return SkyVisualTest + .setupTest('popover') + .then(() => { + SkyVisualTest.scrollElementIntoView('#screenshot-popover-positioned-parent'); + element(by.id('btn-popover-position-parent-right')).click(); + return SkyVisualTest + .compareScreenshot({ + screenshotName: 'popover-position-parent-right', + selector: '#screenshot-popover-positioned-parent' + }); + }); + }); }); diff --git a/src/demos/colorpicker/colorpicker-demo.component.html b/src/demos/colorpicker/colorpicker-demo.component.html index 434564096..e3d3a802d 100644 --- a/src/demos/colorpicker/colorpicker-demo.component.html +++ b/src/demos/colorpicker/colorpicker-demo.component.html @@ -1,5 +1,7 @@
- + -
  • - Default (select) style: -
    - - - Show dropdown - - - - - - - -
  • -
  • - Context menu style: -
    - - - - - - - -
  • -
  • - Icon style: -
    - - - - - - - -
  • -
  • - Select primary style: -
    - - - Show dropdown - - - - - - - -
  • -
  • - Context menu with primary style: -
    - - - - - - - -
  • -
  • - Icon with primary style: -
    - - - - - - - -
  • - + +

    + Button types and styles +

    + + + +
      +
    • + Default (select) style: +
      + + + Show dropdown + + + + + + + +
    • +
    • + Context menu style: +
      + + + + + + + +
    • +
    • + Icon style: +
      + + + + + + + +
    • +
    +
    + +
      +
    • + Select primary style: +
      + + + Show dropdown + + + + + + + +
    • +
    • + Context menu with primary style: +
      + + + + + + + +
    • +
    • + Icon with primary style: +
      + + + + + + + +
    • +
    +
    +
    + +

    + Interacting with a dropdown programmatically +

    + +

    + + + + + + + + + + + +

    + + + Open + + + + + + + +

    + This menu does not bring the active items to focus; this is useful for custom implementations where the focus should remain on a different control. +

    diff --git a/src/demos/dropdown/dropdown-demo.component.ts b/src/demos/dropdown/dropdown-demo.component.ts index fb1c2ac58..1427db82e 100644 --- a/src/demos/dropdown/dropdown-demo.component.ts +++ b/src/demos/dropdown/dropdown-demo.component.ts @@ -1,7 +1,71 @@ -import { Component } from '@angular/core'; +import { + ChangeDetectorRef, + Component +} from '@angular/core'; + +import { Subject } from 'rxjs/Subject'; + +import { + SkyDropdownMessage, + SkyDropdownMessageType, + SkyDropdownMenuChange +} from '../../core'; @Component({ selector: 'sky-dropdown-demo', templateUrl: './dropdown-demo.component.html' }) -export class SkyDropdownDemoComponent { } +export class SkyDropdownDemoComponent { + public dropdownController = new Subject(); + public items: any[] = [ + { name: 'Option 1', disabled: false }, + { name: 'Option 2', disabled: true }, + { name: 'Option 3', disabled: false }, + { name: 'Option 4', disabled: false }, + { name: 'Option 5', disabled: false } + ]; + + constructor( + private changeDetector: ChangeDetectorRef + ) { } + + public optionClicked(option: number) { + alert('You selected option ' + option); + } + + public openDropdown() { + this.sendMessage(SkyDropdownMessageType.Open); + } + + public closeDropdown() { + this.sendMessage(SkyDropdownMessageType.Close); + } + + public focusTriggerButton() { + this.sendMessage(SkyDropdownMessageType.FocusTriggerButton); + } + + public focusNextItem() { + this.sendMessage(SkyDropdownMessageType.FocusNextItem); + } + + public focusPreviousItem() { + this.sendMessage(SkyDropdownMessageType.FocusPreviousItem); + } + + public changeItems() { + this.items.pop(); + this.changeDetector.detectChanges(); + } + + public onMenuChanges(change: SkyDropdownMenuChange) { + if (change.activeIndex !== undefined) { + console.log('The menu\'s active index changed to:', change.activeIndex); + } + } + + private sendMessage(type: SkyDropdownMessageType) { + const message: SkyDropdownMessage = { type }; + this.dropdownController.next(message); + } +} diff --git a/src/demos/popover/popover-demo.component.html b/src/demos/popover/popover-demo.component.html index f95276998..b26b73d50 100644 --- a/src/demos/popover/popover-demo.component.html +++ b/src/demos/popover/popover-demo.component.html @@ -1,22 +1,54 @@
    - - The content of a popover can be text, HTML, or Angular components. + + Right
    - - The content of a popover can be text, HTML, or Angular components. + + Left
    - - The content of a popover can be text, HTML, or Angular components. + + Above (default)
    - - The content of a popover can be text, HTML, or Angular components. + + Above, align left + +
    +
    + + Above, align right + +
    +
    + + Below + +
    +
    + + Below, align left + +
    +
    + + Below, align right
    @@ -52,44 +84,137 @@

    Popovers can be opened via buttons:

    - - - The content of a popover can be text, HTML, or Angular components. + + The content of a popover can be text, HTML, or Angular components.
    + + + + + + + + + + + + +
    - The content of a popover can be text, HTML, or Angular components. + Some link - The content of a popover can be text, HTML, or Angular components. + Some link - + + + The content of a popover can be text, HTML, or Angular components. + + + + + + The content of a popover can be text, HTML, or Angular components. + + + The content of a popover can be text, HTML, or Angular components. - + + + The content of a popover can be text, HTML, or Angular components. + + + + + + The content of a popover can be text, HTML, or Angular components. + + + The content of a popover can be text, HTML, or Angular components. + Some link

    @@ -100,13 +225,13 @@

    Did you know?

    - + The content of a popover can be text, HTML, or Angular components. @@ -118,15 +243,13 @@

    class="sky-btn" [skyPopover]="popoverMouseEnter" skyPopoverPlacement="right" - skyPopoverTrigger="mouseenter" -> + skyPopoverTrigger="mouseenter"> Hover + #popoverMouseEnter> You can move your mouse off the button to close this popover. @@ -175,3 +298,80 @@

    + +

    + Popovers with large amounts of content are displayed fullscreen +

    + + + + +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur posuere placerat mollis. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Praesent accumsan lacus sed volutpat pharetra. Etiam convallis elit ac odio rhoncus molestie. Pellentesque consequat porttitor magna sit amet consectetur. Donec sed accumsan urna, in dapibus erat. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Etiam auctor justo a mattis euismod. Etiam porttitor turpis quis tellus laoreet, quis eleifend diam mollis. In eget risus nulla. Nulla a elit eu lectus laoreet lobortis. Proin rhoncus libero dictum augue consequat lobortis. Vestibulum suscipit dapibus dolor sit amet facilisis. Pellentesque ullamcorper, libero id fringilla tempus, mi orci vestibulum eros, ac pulvinar nibh turpis ac nulla. +

    +

    + Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Nullam cursus arcu magna, at aliquet arcu pellentesque ac. Donec quis elit quis sapien tristique tincidunt. Mauris vel suscipit felis. Curabitur vestibulum vitae ipsum non tincidunt. Integer mollis congue ante. Nulla non vulputate nunc. Aenean eget est et libero venenatis faucibus. In hac habitasse platea dictumst. Suspendisse potenti. Donec interdum, tellus eu finibus auctor, felis turpis pulvinar nibh, quis gravida quam diam at risus. Integer porttitor ipsum eget vehicula vulputate. +

    +

    + Etiam eget scelerisque tellus. Suspendisse vehicula sapien et felis dapibus varius. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Proin tincidunt suscipit nulla. Fusce ullamcorper malesuada lacus vel venenatis. Morbi venenatis mi id diam lacinia facilisis. In nec ipsum nec tortor fringilla sodales at in sapien. Nam accumsan turpis augue, in finibus ligula congue a. Nunc lobortis sem eget libero eleifend consectetur. In euismod maximus aliquam. Maecenas placerat scelerisque cursus. Fusce elementum malesuada sem in elementum. Phasellus congue mi et leo gravida tincidunt. +

    +

    + Proin suscipit nulla tellus, ut lobortis ipsum porttitor eu. Sed pulvinar quam porttitor ligula ornare viverra. Nulla vitae metus lobortis, sagittis mauris ac, gravida diam. Fusce tincidunt massa a odio euismod maximus. Mauris eget dignissim justo. Proin quis nunc est. Duis non blandit ligula. +

    + + + + + + + + + + + + + +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur posuere placerat mollis. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Praesent accumsan lacus sed volutpat pharetra. Etiam convallis elit ac odio rhoncus molestie. Pellentesque consequat porttitor magna sit amet consectetur. Donec sed accumsan urna, in dapibus erat. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Etiam auctor justo a mattis euismod. Etiam porttitor turpis quis tellus laoreet, quis eleifend diam mollis. In eget risus nulla. Nulla a elit eu lectus laoreet lobortis. Proin rhoncus libero dictum augue consequat lobortis. Vestibulum suscipit dapibus dolor sit amet facilisis. Pellentesque ullamcorper, libero id fringilla tempus, mi orci vestibulum eros, ac pulvinar nibh turpis ac nulla. +

    +

    + Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Nullam cursus arcu magna, at aliquet arcu pellentesque ac. Donec quis elit quis sapien tristique tincidunt. Mauris vel suscipit felis. Curabitur vestibulum vitae ipsum non tincidunt. Integer mollis congue ante. Nulla non vulputate nunc. Aenean eget est et libero venenatis faucibus. In hac habitasse platea dictumst. Suspendisse potenti. Donec interdum, tellus eu finibus auctor, felis turpis pulvinar nibh, quis gravida quam diam at risus. Integer porttitor ipsum eget vehicula vulputate. +

    +

    + Etiam eget scelerisque tellus. Suspendisse vehicula sapien et felis dapibus varius. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Proin tincidunt suscipit nulla. Fusce ullamcorper malesuada lacus vel venenatis. Morbi venenatis mi id diam lacinia facilisis. In nec ipsum nec tortor fringilla sodales at in sapien. Nam accumsan turpis augue, in finibus ligula congue a. Nunc lobortis sem eget libero eleifend consectetur. In euismod maximus aliquam. Maecenas placerat scelerisque cursus. Fusce elementum malesuada sem in elementum. Phasellus congue mi et leo gravida tincidunt. +

    +

    + Proin suscipit nulla tellus, ut lobortis ipsum porttitor eu. Sed pulvinar quam porttitor ligula ornare viverra. Nulla vitae metus lobortis, sagittis mauris ac, gravida diam. Fusce tincidunt massa a odio euismod maximus. Mauris eget dignissim justo. Proin quis nunc est. Duis non blandit ligula. +

    + + + + + + + + + + + + + +
    diff --git a/src/demos/popover/popover-demo.component.scss b/src/demos/popover/popover-demo.component.scss index c470540f7..ddaac6a2a 100644 --- a/src/demos/popover/popover-demo.component.scss +++ b/src/demos/popover/popover-demo.component.scss @@ -5,18 +5,18 @@ } .popover-demo-item { - position: relative; padding: 10px; - flex: 1; } -/deep/ .popover-demo-item { - .sky-popover-container { - position: static !important; + ::ng-deep .sky-popover-container.sky-popover-hidden { + position: relative !important; width: 100% !important; opacity: 1 !important; visibility: visible !important; + z-index: 1; + left: auto !important; + top: auto !important; .sky-popover { margin: 0; diff --git a/src/demos/popover/popover-demo.component.ts b/src/demos/popover/popover-demo.component.ts index 849bcbcc6..8cefc1369 100644 --- a/src/demos/popover/popover-demo.component.ts +++ b/src/demos/popover/popover-demo.component.ts @@ -1,41 +1,23 @@ import { Component, - OnInit + ElementRef, + ViewChild } from '@angular/core'; -import { - DomSanitizer, - SafeResourceUrl -} from '@angular/platform-browser'; - -function getWindow() { - return window; -} - @Component({ selector: 'sky-popover-demo', templateUrl: './popover-demo.component.html', styleUrls: ['./popover-demo.component.scss'] }) -export class SkyPopoverDemoComponent implements OnInit { - public outOfBoundsDemoUrl: SafeResourceUrl; - - constructor( - private sanitizer: DomSanitizer - ) { } - - public ngOnInit() { - this.outOfBoundsDemoUrl = this.sanitizer - .bypassSecurityTrustResourceUrl( - `${getWindow().location.href}/out-of-bounds-demo` - ); - } +export class SkyPopoverDemoComponent { + @ViewChild('remote') + public remote: ElementRef; - public onPopoverOpened(popoverComponent: any): void { + public onPopoverOpened(popoverComponent: any) { alert('The popover was opened: ' + popoverComponent.popoverTitle); } - public onPopoverClosed(popoverComponent: any): void { + public onPopoverClosed(popoverComponent: any) { alert('The popover was closed: ' + popoverComponent.popoverTitle); } } diff --git a/src/modules/colorpicker/colorpicker.component.html b/src/modules/colorpicker/colorpicker.component.html index 846a93e4f..179c443fc 100644 --- a/src/modules/colorpicker/colorpicker.component.html +++ b/src/modules/colorpicker/colorpicker.component.html @@ -1,11 +1,10 @@
    - + -
    +
    diff --git a/src/modules/colorpicker/colorpicker.component.scss b/src/modules/colorpicker/colorpicker.component.scss index 25f1ada0a..6bef00254 100644 --- a/src/modules/colorpicker/colorpicker.component.scss +++ b/src/modules/colorpicker/colorpicker.component.scss @@ -1,17 +1,17 @@ -@import "../../scss/variables"; -@import "../../scss/mixins"; +@import '../../scss/mixins'; -// Component modifying host-context element -:host-context(sky-colorpicker) sky-dropdown { - position: relative; - left: -30px; - width: 30px; - overflow: hidden; - height: 30px; -} - -:host-context(sky-colorpicker) sky-dropdown /deep/ .sky-dropdown-button { - color: transparent; +:host-context(sky-colorpicker) { + ::ng-deep .sky-dropdown-button { + padding: 0; + position: relative; + z-index: 1; + border-color: transparent; + left: -30px; + height: 30px; + width: 30px; + color: transparent; + background-color: transparent; + } } :host-context(sky-colorpicker) /deep/ .sky-colorpicker-input { diff --git a/src/modules/colorpicker/colorpicker-component.spec.ts b/src/modules/colorpicker/colorpicker.component.spec.ts similarity index 72% rename from src/modules/colorpicker/colorpicker-component.spec.ts rename to src/modules/colorpicker/colorpicker.component.spec.ts index c40ab4dc6..877360cf6 100644 --- a/src/modules/colorpicker/colorpicker-component.spec.ts +++ b/src/modules/colorpicker/colorpicker.component.spec.ts @@ -1,5 +1,11 @@ -// spell-checker:ignore Colorpicker, dropdown, cmyk, hsla -import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { + async, + fakeAsync, + TestBed, + tick, + ComponentFixture +} from '@angular/core/testing'; + import { By } from '@angular/platform-browser'; import { FormsModule } from '@angular/forms'; import { SkyColorpickerModule } from './colorpicker.module'; @@ -7,11 +13,26 @@ import { ColorpickerTestComponent } from './fixtures/colorpicker-component.fixtu import { expect } from '../testing'; describe('Colorpicker Component', () => { + let fixture: ComponentFixture; + let component: ColorpickerTestComponent; + let nativeElement: HTMLElement; function openColorpicker(element: HTMLElement, compFixture: ComponentFixture) { - let dropdownButtonEl = element.querySelector('.sky-dropdown-button') as HTMLElement; - dropdownButtonEl.click(); - compFixture.detectChanges(); + tick(); + fixture.detectChanges(); + verifyMenuVisibility(false); + + const buttonElem = element.querySelector('.sky-dropdown-button') as HTMLElement; + buttonElem.click(); + tick(); + fixture.detectChanges(); + tick(); + verifyMenuVisibility(); + } + + function verifyMenuVisibility(isVisible = true) { + const popoverElem = fixture.nativeElement.querySelector('.sky-popover-container'); + expect(getComputedStyle(popoverElem).visibility !== 'hidden').toEqual(isVisible); } function setPresetColor(element: HTMLElement, compFixture: ComponentFixture, key: number) { @@ -23,26 +44,6 @@ describe('Colorpicker Component', () => { compFixture.detectChanges(); } - let fixture: ComponentFixture; - let component: ColorpickerTestComponent; - let nativeElement: HTMLElement; - - beforeEach(() => { - TestBed.configureTestingModule({ - declarations: [ - ColorpickerTestComponent - ], - imports: [ - SkyColorpickerModule, - FormsModule - ] - }); - - fixture = TestBed.createComponent(ColorpickerTestComponent); - nativeElement = fixture.nativeElement as HTMLElement; - component = fixture.componentInstance; - }); - function keyHelper(keyName: string, key: number, deprecatedKeyName: string) { let document = nativeElement.parentNode.parentNode.parentNode; let keyPress: KeyboardEvent; @@ -65,8 +66,8 @@ describe('Colorpicker Component', () => { } function mouseHelper(x: number, y: number, event: string) { - let document = nativeElement.parentNode.parentNode.parentNode; + try { // Deprecated browser API... IE let mouseEventDeprecated = document.createEvent('MouseEvents'); @@ -100,16 +101,13 @@ describe('Colorpicker Component', () => { } function getElementCords(elementRef: any) { - let el = elementRef.nativeElement; - let parent = el.offsetParent; - // Avoid box model issues in IE and by moving color picker top left. - parent.style.left = '0px'; - parent.style.top = '0px'; - let left = el.offsetLeft; - let top = el.scrollHeight / 2; - let width = el.offsetWidth; - let xMiddle = left + (width / 2); - return { 'middle': xMiddle, 'top': top }; + const rect = (elementRef.nativeElement as HTMLElement).getBoundingClientRect(); + const coords = { + x: Math.round(rect.left + (rect.width / 2)), + y: Math.round(rect.top + (rect.height / 2)) + }; + + return coords; } function setInputElementValue(element: HTMLElement, name: string, value: string) { @@ -140,51 +138,78 @@ describe('Colorpicker Component', () => { return input[name]; } - it('should output RGBA', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ + ColorpickerTestComponent + ], + imports: [ + SkyColorpickerModule, + FormsModule + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ColorpickerTestComponent); + nativeElement = fixture.nativeElement as HTMLElement; + component = fixture.componentInstance; + }); + + it('should output RGBA', fakeAsync(() => { component.selectedOutputFormat = 'rgba'; openColorpicker(nativeElement, fixture); setPresetColor(nativeElement, fixture, 4); verifyColorpicker(nativeElement, 'rgba(189,64,64,1)', '189, 64, 64'); - }); + })); + + it('should handle undefined initial color', fakeAsync(() => { + fixture.detectChanges(); + fixture.destroy(); + + fixture = TestBed.createComponent(ColorpickerTestComponent); + nativeElement = fixture.nativeElement as HTMLElement; + component = fixture.componentInstance; - it('should handle undefined initial color', () => { + component.selectedHexType = 'hex8'; component.selectedOutputFormat = 'hex'; component.selectedColor = undefined; + openColorpicker(nativeElement, fixture); verifyColorpicker(nativeElement, '#fff', '255, 255, 255'); - }); + })); - it('should output HEX', () => { + it('should output HEX', fakeAsync(() => { component.selectedOutputFormat = 'hex'; openColorpicker(nativeElement, fixture); setPresetColor(nativeElement, fixture, 4); verifyColorpicker(nativeElement, '#bd4040', '189, 64, 64'); - }); + })); - it('Should accept a new HEX3 color.', () => { + it('Should accept a new HEX3 color.', fakeAsync(() => { component.selectedOutputFormat = 'rgba'; openColorpicker(nativeElement, fixture); setInputElementValue(nativeElement, 'hex', '#BC4'); verifyColorpicker(nativeElement, 'rgba(187,204,68,1)', '187, 204, 68'); - }); + })); - it('Should accept a new HEX6 color.', () => { + it('Should accept a new HEX6 color.', fakeAsync(() => { component.selectedOutputFormat = 'hex'; openColorpicker(nativeElement, fixture); setInputElementValue(nativeElement, 'hex', '#BFF666'); verifyColorpicker(nativeElement, '#bff666', '191, 246, 102'); - }); + })); - it('Should accept a new RGB color.', () => { + it('Should accept a new RGB color.', fakeAsync(() => { component.selectedOutputFormat = 'hex'; openColorpicker(nativeElement, fixture); setInputElementValue(nativeElement, 'red', '77'); setInputElementValue(nativeElement, 'green', '58'); setInputElementValue(nativeElement, 'blue', '183'); verifyColorpicker(nativeElement, '#4d3ab7', '77, 58, 183'); - }); + })); - it('Should accept a new RGBA color.', () => { + it('Should accept a new RGBA color.', fakeAsync(() => { component.selectedOutputFormat = 'hex'; openColorpicker(nativeElement, fixture); setInputElementValue(nativeElement, 'red', '163'); @@ -192,23 +217,23 @@ describe('Colorpicker Component', () => { setInputElementValue(nativeElement, 'blue', '84'); setInputElementValue(nativeElement, 'alpha', '0.3'); verifyColorpicker(nativeElement, '#a31354', '163, 19, 84, 0.3'); - }); + })); - it('Should accept a new HSL color.', () => { + it('Should accept a new HSL color.', fakeAsync(() => { component.selectedOutputFormat = 'hex'; openColorpicker(nativeElement, fixture); setInputElementValue(nativeElement, 'hex', 'hsl(113,78%,41%)'); verifyColorpicker(nativeElement, '#2aba17', '42, 186, 23'); - }); + })); - it('Should accept a new HSLA color.', () => { + it('Should accept a new HSLA color.', fakeAsync(() => { component.selectedOutputFormat = 'hex'; openColorpicker(nativeElement, fixture); setInputElementValue(nativeElement, 'hex', 'hsla(231,66%,41%,0.62)'); verifyColorpicker(nativeElement, '#2438ae', '36, 56, 174'); - }); + })); - it('Should allow user to click cancel the color change.', () => { + it('Should allow user to click cancel the color change.', fakeAsync(() => { let button = nativeElement.querySelector('.sky-btn-colorpicker-close'); let buttonEvent = document.createEvent('Event'); component.selectedOutputFormat = 'hex'; @@ -218,9 +243,9 @@ describe('Colorpicker Component', () => { buttonEvent.initEvent('click', true, false); button.dispatchEvent(buttonEvent); verifyColorpicker(nativeElement, '#2889e5', '40, 137, 229'); - }); + })); - it('Should allow user to click apply the color change.', () => { + it('Should allow user to click apply the color change.', fakeAsync(() => { let button = nativeElement.querySelector('.sky-btn-colorpicker-apply'); let buttonEvent = document.createEvent('Event'); component.selectedOutputFormat = 'hex'; @@ -230,105 +255,120 @@ describe('Colorpicker Component', () => { buttonEvent.initEvent('click', true, false); button.dispatchEvent(buttonEvent); verifyColorpicker(nativeElement, '#2b7230', '43, 114, 48'); - }); + })); - it('Should accept mouse down events on hue bar.', () => { + it('Should accept mouse down events on hue bar.', fakeAsync(() => { component.selectedOutputFormat = 'hex'; openColorpicker(nativeElement, fixture); - let hueBar = fixture.debugElement.query(By.css('.hue')); - let axis = getElementCords(hueBar); - hueBar.triggerEventHandler('mousedown', { 'pageX': axis.middle, 'pageY': axis.top }); + + const hueBar = fixture.debugElement.query(By.css('.hue')); + const axis = getElementCords(hueBar); + + hueBar.triggerEventHandler('mousedown', { 'pageX': axis.x, 'pageY': axis.y }); fixture.detectChanges(); verifyColorpicker(nativeElement, '#28e5e5', '40, 229, 229'); - hueBar.triggerEventHandler('mousedown', { 'pageX': axis.middle - 50, 'pageY': axis.top }); + + hueBar.triggerEventHandler('mousedown', { 'pageX': axis.x - 50, 'pageY': axis.y }); fixture.detectChanges(); verifyColorpicker(nativeElement, '#a3e528', '163, 229, 40'); - hueBar.triggerEventHandler('mousedown', { 'pageX': axis.middle + 50, 'pageY': axis.top }); + + hueBar.triggerEventHandler('mousedown', { 'pageX': axis.x + 50, 'pageY': axis.y }); fixture.detectChanges(); verifyColorpicker(nativeElement, '#a328e5', '163, 40, 229'); - }); + })); - it('Should accept mouse down events on alpha bar.', () => { + it('Should accept mouse down events on alpha bar.', fakeAsync(() => { component.selectedOutputFormat = 'rgba'; openColorpicker(nativeElement, fixture); - let alphaBar = fixture.debugElement.query(By.css('.alpha')); - let axis = getElementCords(alphaBar); - alphaBar.triggerEventHandler('mousedown', { 'pageX': axis.middle, 'pageY': axis.top }); + + const alphaBar = fixture.debugElement.query(By.css('.alpha')); + const axis = getElementCords(alphaBar); + + alphaBar.triggerEventHandler('mousedown', { 'pageX': axis.x, 'pageY': axis.y }); fixture.detectChanges(); verifyColorpicker(nativeElement, 'rgba(40,137,229,0.5)', '40, 137, 229, 0.5'); - alphaBar.triggerEventHandler('mousedown', { 'pageX': axis.middle - 50, 'pageY': axis.top }); + + alphaBar.triggerEventHandler('mousedown', { 'pageX': axis.x - 50, 'pageY': axis.y }); fixture.detectChanges(); verifyColorpicker(nativeElement, 'rgba(40,137,229,0.23)', '40, 137, 229, 0.23'); - alphaBar.triggerEventHandler('mousedown', { 'pageX': axis.middle + 50, 'pageY': axis.top }); + + alphaBar.triggerEventHandler('mousedown', { 'pageX': axis.x + 50, 'pageY': axis.y }); fixture.detectChanges(); verifyColorpicker(nativeElement, 'rgba(40,137,229,0.77)', '40, 137, 229, 0.77'); - }); + })); - it('Should accept mouse down events on saturation and lightness.', () => { + it('Should accept mouse down events on saturation and lightness.', fakeAsync(() => { component.selectedOutputFormat = 'hex'; openColorpicker(nativeElement, fixture); - let slBar = fixture.debugElement.query(By.css('.saturation-lightness')); - let axis = getElementCords(slBar); - slBar.triggerEventHandler('mousedown', { 'pageX': axis.middle, 'pageY': axis.top }); + + const slBar = fixture.debugElement.query(By.css('.saturation-lightness')); + const axis = getElementCords(slBar); + + slBar.triggerEventHandler('mousedown', { 'pageX': axis.x, 'pageY': axis.y }); fixture.detectChanges(); verifyColorpicker(nativeElement, '#406080', '64, 96, 128'); - slBar.triggerEventHandler('mousedown', { 'pageX': axis.middle - 50, 'pageY': axis.top }); + + slBar.triggerEventHandler('mousedown', { 'pageX': axis.x - 50, 'pageY': axis.y }); fixture.detectChanges(); verifyColorpicker(nativeElement, '#576b80', '87, 107, 128'); - slBar.triggerEventHandler('mousedown', { 'pageX': axis.middle - 50, 'pageY': axis.top / 2 }); + + slBar.triggerEventHandler('mousedown', { 'pageX': axis.x - 50, 'pageY': axis.y / 2 }); fixture.detectChanges(); - verifyColorpicker(nativeElement, '#83a0bf', '131, 160, 191'); - slBar.triggerEventHandler('mousedown', { 'pageX': axis.middle, 'pageY': axis.top / 2 }); + verifyColorpicker(nativeElement, '#92b3d6', '146, 179, 214'); + + slBar.triggerEventHandler('mousedown', { 'pageX': axis.x, 'pageY': axis.y / 2 }); fixture.detectChanges(); - verifyColorpicker(nativeElement, '#608ebf', '96, 142, 191'); - slBar.triggerEventHandler('mousedown', { 'pageX': axis.middle + 50, 'pageY': axis.top / 2 }); + verifyColorpicker(nativeElement, '#6b9fd6', '107, 159, 214'); + + slBar.triggerEventHandler('mousedown', { 'pageX': axis.x + 50, 'pageY': axis.y / 2 }); fixture.detectChanges(); - verifyColorpicker(nativeElement, '#3c7cbf', '60, 124, 191'); - slBar.triggerEventHandler('mousedown', { 'pageX': axis.middle + 50, 'pageY': axis.top }); + verifyColorpicker(nativeElement, '#438ad6', '67, 138, 214'); + + slBar.triggerEventHandler('mousedown', { 'pageX': axis.x + 50, 'pageY': axis.y }); fixture.detectChanges(); - verifyColorpicker(nativeElement, '#285380', '40, 83, 128'); - }); + verifyColorpicker(nativeElement, '#285280', '40, 82, 128'); + })); - it('Should accept mouse dragging on saturation and lightness.', () => { + it('Should accept mouse dragging on saturation and lightness.', fakeAsync(() => { component.selectedOutputFormat = 'hex'; openColorpicker(nativeElement, fixture); let slBar = fixture.debugElement.query(By.css('.saturation-lightness')); let axis = getElementCords(slBar); - slBar.triggerEventHandler('mousedown', { 'pageX': axis.middle, 'pageY': axis.top }); + slBar.triggerEventHandler('mousedown', { 'pageX': axis.x, 'pageY': axis.y }); fixture.detectChanges(); - mouseHelper(axis.middle - 50, axis.top - 50, 'mousemove'); + mouseHelper(axis.x - 50, axis.y - 50, 'mousemove'); fixture.detectChanges(); verifyColorpicker(nativeElement, '#8babcb', '139, 171, 203'); - mouseHelper(axis.middle + 50, axis.top, 'mousemove'); + mouseHelper(axis.x + 50, axis.y, 'mousemove'); verifyColorpicker(nativeElement, '#285480', '40, 84, 128'); - mouseHelper(axis.middle + 50, axis.top, 'mouseup'); + mouseHelper(axis.x + 50, axis.y, 'mouseup'); verifyColorpicker(nativeElement, '#285480', '40, 84, 128'); fixture.detectChanges(); - }); + })); - it('Should output HSLA in css format.', () => { + it('Should output HSLA in css format.', fakeAsync(() => { component.selectedOutputFormat = 'hsla'; openColorpicker(nativeElement, fixture); setInputElementValue(nativeElement, 'hex', '#123456'); verifyColorpicker(nativeElement, 'hsla(210,65%,20%,1)', '18, 51, 84'); - }); + })); - it('Should accept HEX8 alpha conversions.', () => { + it('Should accept HEX8 alpha conversions.', fakeAsync(() => { component.selectedHexType = 'hex8'; component.selectedOutputFormat = 'rgba'; openColorpicker(nativeElement, fixture); setInputElementValue(nativeElement, 'hex', '#12345680'); verifyColorpicker(nativeElement, 'rgba(18,52,86,0.5)', '18, 52, 86, 0.5'); - }); + })); - it('Should output CMYK in css format.', () => { + it('Should output CMYK in css format.', fakeAsync(() => { component.selectedOutputFormat = 'cmyk'; openColorpicker(nativeElement, fixture); setInputElementValue(nativeElement, 'hex', '#654321'); verifyColorpicker(nativeElement, 'cmyk(0%,34%,67%,60%)', '101, 67, 33'); - }); + })); - it('Should accept transparency', () => { + it('Should accept transparency', fakeAsync(() => { component.selectedOutputFormat = 'hsla'; openColorpicker(nativeElement, fixture); setInputElementValue(nativeElement, 'red', '0'); @@ -336,9 +376,9 @@ describe('Colorpicker Component', () => { setInputElementValue(nativeElement, 'blue', '0'); setInputElementValue(nativeElement, 'alpha', '0'); verifyColorpicker(nativeElement, 'hsla(0,0%,0%,0)', '0, 0, 0, 0'); - }); + })); - it('Should accept color change through directive host listener', () => { + it('Should accept color change through directive host listener', fakeAsync(() => { component.selectedOutputFormat = 'rgba'; openColorpicker(nativeElement, fixture); nativeElement.querySelector('input').value = '#4523FC'; @@ -350,9 +390,9 @@ describe('Colorpicker Component', () => { nativeElement.querySelector('input').dispatchEvent(changeEvent); fixture.detectChanges(); verifyColorpicker(nativeElement, 'rgba(69,35,252,1)', '69, 35, 252'); - }); + })); - it('Should allow user to esc cancel the color change.', () => { + it('Should allow user to esc cancel the color change.', fakeAsync(() => { component.selectedOutputFormat = 'hex'; openColorpicker(nativeElement, fixture); setInputElementValue(nativeElement, 'hex', '#086A93'); @@ -360,11 +400,11 @@ describe('Colorpicker Component', () => { keyHelper('Escape', 27, 'Esc'); fixture.detectChanges(); verifyColorpicker(nativeElement, '#2889e5', '40, 137, 229'); - }); + })); - it('Should specify type="button" on all button elements.', () => { + it('Should specify type="button" on all button elements.', fakeAsync(() => { component.selectedOutputFormat = 'hex'; openColorpicker(nativeElement, fixture); expect(nativeElement.querySelectorAll('button:not([type="button"])').length).toBe(0); - }); + })); }); diff --git a/src/modules/colorpicker/colorpicker.component.ts b/src/modules/colorpicker/colorpicker.component.ts index 7d094e01a..ec1f633a1 100644 --- a/src/modules/colorpicker/colorpicker.component.ts +++ b/src/modules/colorpicker/colorpicker.component.ts @@ -9,6 +9,13 @@ import { Component } from '@angular/core'; +import { Subject } from 'rxjs/Subject'; + +import { + SkyDropdownMessage, + SkyDropdownMessageType +} from '../dropdown'; + import { SkyColorpickerChangeColor } from './types/colorpicker-color'; import { SkyColorpickerChangeAxis } from './types/colorpicker-axis'; import { SkyColorpickerHsla } from './types/colorpicker-hsla'; @@ -57,6 +64,8 @@ export class SkyColorpickerComponent implements OnInit { public slider: SliderPosition; public initialColor: string; + public dropdownController = new Subject(); + @ViewChild('closeColorPicker') private closeColorPicker: ElementRef; @@ -76,18 +85,6 @@ export class SkyColorpickerComponent implements OnInit { this.skyColorpickerAlphaId = 'sky-colorpicker-alpha-' + this.idIndex; } - public onContainerClick(event: MouseEvent) { - const element: HTMLButtonElement = event.target; - // Allow the click event to propagate to the dropdown handler for certain buttons. - // (This will allow the dropdown menu to close.) - if ( - !element.classList.contains('sky-btn-colorpicker-close') && - !element.classList.contains('sky-btn-colorpicker-apply') - ) { - event.stopPropagation(); - } - } - @HostListener('document:keydown', ['$event']) public keyboardInput(event: any) { /* Ignores in place for valid code that is only used in IE and Edge */ @@ -129,11 +126,13 @@ export class SkyColorpickerComponent implements OnInit { public closePicker() { this.setColorFromString(this.initialColor); + this.closeDropdown(); } public applyColor() { this.selectedColorChanged.emit(this.selectedColor); this.initialColor = this.selectedColor.rgbaText; + this.closeDropdown(); } public setColorFromString(value: string) { @@ -242,4 +241,10 @@ export class SkyColorpickerComponent implements OnInit { this.selectedColorChanged.emit(this.selectedColor); } } + + private closeDropdown() { + this.dropdownController.next({ + type: SkyDropdownMessageType.Close + }); + } } diff --git a/src/modules/colorpicker/fixtures/colorpicker-component.fixture.ts b/src/modules/colorpicker/fixtures/colorpicker-component.fixture.ts index 54591eef7..fc1ae6f5a 100644 --- a/src/modules/colorpicker/fixtures/colorpicker-component.fixture.ts +++ b/src/modules/colorpicker/fixtures/colorpicker-component.fixture.ts @@ -1,4 +1,3 @@ -// spell-checker:ignore Colorpicker import { Component } from '@angular/core'; @Component({ @@ -6,10 +5,10 @@ import { Component } from '@angular/core'; templateUrl: './colorpicker-component.fixture.html' }) export class ColorpickerTestComponent { - public selectedHexType: string = 'hex6'; - public selectedColor: string = '#2889e5'; - public selectedOutputFormat: string = 'rgba'; - public presetColors = [ + public selectedHexType = 'hex6'; + public selectedColor = '#2889e5'; + public selectedOutputFormat = 'rgba'; + public presetColors: string[] = [ '#333333', '#888888', '#EFEFEF', @@ -23,6 +22,4 @@ export class ColorpickerTestComponent { '#A1B1A7', '#68AFEF' ]; - - public constructor() { } } diff --git a/src/modules/datepicker/datepicker-calendar.component.scss b/src/modules/datepicker/datepicker-calendar.component.scss index b1619392d..70dbff7e3 100644 --- a/src/modules/datepicker/datepicker-calendar.component.scss +++ b/src/modules/datepicker/datepicker-calendar.component.scss @@ -1,3 +1,3 @@ .sky-datepicker-calendar { - display: inline-block; + display: block; } diff --git a/src/modules/datepicker/datepicker.component.html b/src/modules/datepicker/datepicker.component.html index b85f68116..6f2192d10 100644 --- a/src/modules/datepicker/datepicker.component.html +++ b/src/modules/datepicker/datepicker.component.html @@ -1,18 +1,21 @@ -
    - +
    +
    + -
    - - - - - - +
    + + + + + + +
    diff --git a/src/modules/datepicker/datepicker.component.scss b/src/modules/datepicker/datepicker.component.scss index 99544b8f7..b6f65539d 100644 --- a/src/modules/datepicker/datepicker.component.scss +++ b/src/modules/datepicker/datepicker.component.scss @@ -2,7 +2,7 @@ .sky-input-group-datepicker-btn /deep/ .sky-dropdown-button.sky-btn { border-radius: 0; - border-left: none; + border-left-color: transparent; &:hover{ @include sky-border(dark, left); } @@ -13,3 +13,12 @@ background-color: transparent; text-align: center; } + +.sky-datepicker { + ::ng-deep .sky-popover-container { + .sky-popover { + box-shadow: none; + background-color: transparent; + } + } +} diff --git a/src/modules/datepicker/datepicker.component.spec.ts b/src/modules/datepicker/datepicker.component.spec.ts index c99959db8..51fca4c78 100644 --- a/src/modules/datepicker/datepicker.component.spec.ts +++ b/src/modules/datepicker/datepicker.component.spec.ts @@ -5,6 +5,10 @@ import { tick } from '@angular/core/testing'; +import { + NoopAnimationsModule +} from '@angular/platform-browser/animations'; + import { FormsModule, NgModel @@ -94,6 +98,7 @@ describe('datepicker', () => { let fixture: ComponentFixture; let component: DatepickerNoFormatTestComponent; let nativeElement: HTMLElement; + it('should handle different format from configuration', fakeAsync(() => { TestBed.configureTestingModule({ declarations: [ @@ -101,6 +106,7 @@ describe('datepicker', () => { ], imports: [ SkyDatepickerModule, + NoopAnimationsModule, FormsModule ] }); @@ -111,13 +117,13 @@ describe('datepicker', () => { { provide: SkyDatepickerConfigService, useValue: { - dateFormat: 'DD/MM/YYYY' - } + dateFormat: 'DD/MM/YYYY' + } } ] } - }) - .createComponent(DatepickerNoFormatTestComponent); + }).createComponent(DatepickerNoFormatTestComponent); + nativeElement = fixture.nativeElement as HTMLElement; component = fixture.componentInstance; @@ -135,6 +141,7 @@ describe('datepicker', () => { let fixture: ComponentFixture; let component: DatepickerTestComponent; let nativeElement: HTMLElement; + beforeEach(() => { TestBed.configureTestingModule({ declarations: [ @@ -142,6 +149,7 @@ describe('datepicker', () => { ], imports: [ SkyDatepickerModule, + NoopAnimationsModule, FormsModule ] }); @@ -152,7 +160,6 @@ describe('datepicker', () => { }); it('should create the component with the appropriate styles', () => { - fixture.detectChanges(); expect(nativeElement.querySelector('input')).toHaveCssClass('sky-form-control'); expect(nativeElement @@ -160,22 +167,26 @@ describe('datepicker', () => { .not.toBeNull(); }); - it('should keep the calendar open on mode change', () => { + it('should keep the calendar open on mode change', fakeAsync(() => { fixture.detectChanges(); openDatepicker(nativeElement, fixture); + tick(); + fixture.detectChanges(); + tick(); - let dropdownMenuEl = nativeElement.querySelector('.sky-dropdown-menu'); - expect(dropdownMenuEl).toHaveCssClass('sky-dropdown-open'); + let dropdownMenuEl = nativeElement.querySelector('.sky-popover-container'); + expect(dropdownMenuEl).not.toHaveCssClass('sky-popover-hidden'); - let titleEl - = nativeElement.querySelector('.sky-datepicker-calendar-title') as HTMLButtonElement; + let titleEl = nativeElement.querySelector('.sky-datepicker-calendar-title') as HTMLButtonElement; titleEl.click(); + tick(); fixture.detectChanges(); + tick(); - dropdownMenuEl = nativeElement.querySelector('.sky-dropdown-menu'); - expect(dropdownMenuEl).toHaveCssClass('sky-dropdown-open'); - }); + dropdownMenuEl = nativeElement.querySelector('.sky-popover-container'); + expect(dropdownMenuEl).not.toHaveCssClass('sky-popover-hidden'); + })); it('should pass date back when date is selected in calendar', fakeAsync(() => { component.selectedDate = new Date('5/12/2017'); @@ -183,6 +194,7 @@ describe('datepicker', () => { openDatepicker(nativeElement, fixture); tick(); fixture.detectChanges(); + tick(); expect(nativeElement.querySelector('td .sky-datepicker-btn-selected')) .toHaveText('12'); @@ -196,12 +208,12 @@ describe('datepicker', () => { .querySelectorAll('tbody tr td .sky-btn-default').item(2) as HTMLButtonElement; dateButtonEl.click(); + tick(); fixture.detectChanges(); + tick(); expect(component.selectedDate).toEqual(new Date('5/2/2017')); - expect(nativeElement.querySelector('input').value).toBe('05/02/2017'); - })); describe('initialization', () => { @@ -313,6 +325,9 @@ describe('datepicker', () => { setInput(nativeElement, '5/12/2017', fixture); openDatepicker(nativeElement, fixture); + tick(); + fixture.detectChanges(); + tick(); expect(nativeElement.querySelector('td .sky-datepicker-btn-selected')) .toHaveText('12'); @@ -500,9 +515,10 @@ describe('datepicker', () => { fixture.detectChanges(); setInput(fixture.nativeElement, 'abcdebf', fixture); + tick(); openDatepicker(fixture.nativeElement, fixture); - + tick(); })); it('should handle noValidate property', fakeAsync(() => { @@ -564,13 +580,13 @@ describe('datepicker', () => { fixture.detectChanges(); openDatepicker(fixture.nativeElement, fixture); + tick(); let dateButtonEl = fixture.nativeElement .querySelectorAll('tbody tr td .sky-btn-default').item(30) as HTMLButtonElement; expect(dateButtonEl).toHaveCssClass('sky-btn-disabled'); - })); it('should pass min date to calendar', fakeAsync(() => { @@ -581,6 +597,7 @@ describe('datepicker', () => { fixture.detectChanges(); openDatepicker(fixture.nativeElement, fixture); + tick(); let dateButtonEl = fixture.nativeElement @@ -615,10 +632,11 @@ describe('datepicker', () => { ], imports: [ SkyDatepickerModule, + NoopAnimationsModule, FormsModule ], providers: [ - { provide: SkyWindowRefService, useValue: mockWindowService } + { provide: SkyWindowRefService, useValue: mockWindowService } ] }); diff --git a/src/modules/datepicker/datepicker.component.ts b/src/modules/datepicker/datepicker.component.ts index 277b11570..653394538 100644 --- a/src/modules/datepicker/datepicker.component.ts +++ b/src/modules/datepicker/datepicker.component.ts @@ -4,12 +4,14 @@ import { ViewChild } from '@angular/core'; -import { - SkyDatepickerCalendarComponent -} from './datepicker-calendar.component'; +import { Subject } from 'rxjs/Subject'; + +import { SkyDatepickerCalendarComponent } from './datepicker-calendar.component'; import { - SkyDropdownComponent + SkyDropdownComponent, + SkyDropdownMessage, + SkyDropdownMessageType } from '../dropdown'; @Component({ @@ -18,21 +20,22 @@ import { styleUrls: ['./datepicker.component.scss'] }) export class SkyDatepickerComponent { - - public dateChanged: EventEmitter = new EventEmitter(); - - public maxDate: Date; - - public minDate: Date; - @ViewChild(SkyDatepickerCalendarComponent) public calendar: SkyDatepickerCalendarComponent; @ViewChild(SkyDropdownComponent) public dropdown: SkyDropdownComponent; + public dropdownController = new Subject(); + public dateChanged: EventEmitter = new EventEmitter(); + public maxDate: Date; + public minDate: Date; + public dateSelected(newDate: Date) { this.dateChanged.emit(newDate); + this.dropdownController.next({ + type: SkyDropdownMessageType.Close + }); } public setSelectedDate(newDate: Date) { @@ -48,9 +51,8 @@ export class SkyDatepickerComponent { } public onCalendarModeChange() { - setTimeout(() => { - this.dropdown.resetDropdownPosition(); + this.dropdownController.next({ + type: SkyDropdownMessageType.Reposition }); } - } diff --git a/src/modules/dropdown/dropdown-adapter.service.ts b/src/modules/dropdown/dropdown-adapter.service.ts deleted file mode 100644 index 2c2b0febf..000000000 --- a/src/modules/dropdown/dropdown-adapter.service.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { - ElementRef, - EventEmitter, - Injectable, - Renderer -} from '@angular/core'; - -const CLS_OPEN = 'sky-dropdown-open'; -const CLS_NO_SCROLL = 'sky-dropdown-no-scroll'; - -@Injectable() -export class SkyDropdownAdapterService { - public dropdownClose = new EventEmitter(); - private scrollListeners: Array = []; - - constructor() { - } - - public showDropdown( - dropdownEl: ElementRef, - renderer: Renderer, - windowObj: Window, - alignment: string) { - - let menuEl = this.getMenuEl(dropdownEl); - - /* istanbul ignore else */ - /* sanity check */ - if (!menuEl.classList.contains(CLS_OPEN)) { - renderer.setElementClass(menuEl, CLS_OPEN, true); - let isFullScreen = this.setMenuLocation(dropdownEl, renderer, windowObj, alignment); - - // Do not need to disable scroll when in full screen dropdown mode - if (!isFullScreen) { - this.scrollListeners = this.setupParentScrollHandler(dropdownEl, windowObj, renderer); - } - - } - } - - public hideDropdown(dropdownEl: ElementRef, renderer: Renderer, windowObj: Window) { - let menuEl = this.getMenuEl(dropdownEl); - - if (menuEl.classList.contains(CLS_OPEN)) { - - this.setDropdownDefaults(menuEl, renderer, windowObj, false); - this.dropdownClose.emit(undefined); - - if (this.scrollListeners.length > 0) { - for (let i = 0; i < this.scrollListeners.length; i++) { - this.scrollListeners[i](); - } - - this.scrollListeners = []; - } - } - } - - public setMenuLocation( - dropdownEl: ElementRef, - renderer: Renderer, - windowObj: Window, - alignment: string) { - - let buttonEl = this.getButtonEl(dropdownEl); - let menuEl = this.getMenuEl(dropdownEl); - - let possiblePositions = ['below', 'above', 'ycenter', 'center', 'ybottom', 'ytop']; - let possibleAlignments = [alignment]; - if (alignment === 'right') { - possibleAlignments.push('left'); - } else { - possibleAlignments.push('right'); - } - let i: number; - let n: number; - for (n = 0; n < possibleAlignments.length; n++) { - for (i = 0; i < possiblePositions.length; i++) { - let menuCoordinates = this.getElementCoordinates( - buttonEl, - menuEl, - possiblePositions[i], - possibleAlignments[n]); - - // Check if visible in viewport - let elementVisibility = this.getElementVisibility( - menuCoordinates.left, - menuCoordinates.top, - menuEl, - windowObj); - - if (elementVisibility.fitsInViewPort) { - renderer.setElementStyle(menuEl, 'top', menuCoordinates.top + 'px'); - renderer.setElementStyle(menuEl, 'left', menuCoordinates.left + 'px'); - return false; - } - } - } - - /* - None of the positions allowed the menu to be fully visible. - In this case we put it in the upper left corner and set the max-height and width. - */ - this.setDropdownDefaults(menuEl, renderer, windowObj, true); - - return true; - } - - private setupParentScrollHandler( - dropdownEl: ElementRef, - windowObj: Window, - renderer: Renderer): Array { - - let parentEls = this.getAllScrollableParentEl(dropdownEl, windowObj); - let listeners: Array = []; - /* istanbul ignore else */ - /* sanity check */ - for (let i = 0; i < parentEls.length; i++) { - let listener: Function; - let parentEl = parentEls[i]; - if (parentEl === document.body) { - listener = renderer.listenGlobal('window', 'scroll', () => { - this.dropdownClose.emit(undefined); - this.hideDropdown(dropdownEl, renderer, windowObj); - }); - - } else { - listener = renderer.listen(parentEl, 'scroll', () => { - this.dropdownClose.emit(undefined); - this.hideDropdown(dropdownEl, renderer, windowObj); - }); - } - - listeners.push(listener); - - } - return listeners; - } - - private setDropdownDefaults( - menuEl: HTMLElement, - renderer: Renderer, - windowObj: Window, - isOpen: boolean) { - renderer.setElementClass(menuEl, CLS_OPEN, isOpen); - renderer.setElementClass(document.body, CLS_NO_SCROLL, isOpen); - let topLeftVal = isOpen ? '10px' : ''; - let heightVal = isOpen ? (windowObj.innerHeight - 20) + 'px' : ''; - let widthVal = isOpen ? (windowObj.innerWidth - 20) + 'px' : ''; - let overflowVal = isOpen ? 'auto' : ''; - this.setMenuStyles( - renderer, - menuEl, - topLeftVal, - heightVal, - widthVal, - overflowVal - ); - } - - private setMenuStyles( - renderer: Renderer, - menuEl: HTMLElement, - topLeftVal: string, - heightVal: string, - widthVal: string, - overflowVal: string) { - - renderer.setElementStyle(menuEl, 'top', topLeftVal); - renderer.setElementStyle(menuEl, 'left', topLeftVal); - renderer.setElementStyle(menuEl, 'max-height', heightVal); - renderer.setElementStyle(menuEl, 'max-width', widthVal); - renderer.setElementStyle(menuEl, 'height', heightVal); - renderer.setElementStyle(menuEl, 'width', widthVal); - renderer.setElementStyle(menuEl, 'overflow-y', overflowVal); - renderer.setElementStyle(menuEl, 'overflow-x', overflowVal); - } - - private getElementCoordinates( - originEl: HTMLElement, - fixedEl: HTMLElement, - position: string, - alignment: string) { - - let fixedRect = fixedEl.getBoundingClientRect(); - let originRect = originEl.getBoundingClientRect(); - - let leftPos: number; - let topPos: number; - - if (position === 'center') { - leftPos = originRect.left + (originRect.width / 2) - (fixedRect.width / 2); - topPos = originRect.top + (originRect.height / 2) - (fixedRect.height / 2); - - return { - left: leftPos, - top: topPos - }; - } - - if (alignment === 'right') { - if (fixedRect.width > originRect.width) { - leftPos = originRect.left - (fixedRect.width - originRect.width); - } else { - leftPos = originRect.left + (originRect.width - fixedRect.width); - } - } else { - leftPos = originRect.left; - } - - if (position === 'below') { - topPos = originRect.top + originEl.offsetHeight; - } - - if (position === 'above') { - topPos = originRect.top - fixedRect.height; - } - - if (position === 'ycenter') { - topPos = originRect.top + (originRect.height / 2) - (fixedRect.height / 2); - } - - if (position === 'ybottom') { - topPos = fixedRect.height; - } - - if (position === 'ytop') { - topPos = 0; - } - - return { - left: leftPos, - top: topPos - }; - } - - private getElementVisibility( - leftPos: number, - topPos: number, - el: HTMLElement, - windowObj: Window): any { - - let elRect = el.getBoundingClientRect(); - - let hiddenRightArea = leftPos + elRect.width - windowObj.innerWidth; - let hiddenLeftArea = 0 - leftPos; - let hiddenBottomArea = topPos + elRect.height - windowObj.innerHeight; - let hiddenTopArea = 0 - topPos; - - let visibleMenuWidth - = elRect.width - Math.max(0, hiddenRightArea) - Math.max(0, hiddenLeftArea); - - let visibleMenuHeight - = elRect.height - Math.max(0, hiddenBottomArea) - Math.max(0, hiddenTopArea); - - let visibleArea = visibleMenuWidth * visibleMenuHeight; - let fitsInViewPort = (elRect.width * elRect.height) === visibleArea; - - return { - visibleArea: visibleArea, - fitsInViewPort: fitsInViewPort - }; - } - - private getAllScrollableParentEl(el: ElementRef, windowObj: Window): Array { - let overflowY: string, - result: Array = [document.body], - parentEl = el.nativeElement.parentNode; - - while ( - parentEl !== undefined && - parentEl instanceof HTMLElement && - parentEl !== document.body) { - - overflowY = windowObj.getComputedStyle(parentEl, undefined).overflowY; - - /*istanbul ignore else */ - /* sanity check */ - if (overflowY) { - switch (overflowY.toUpperCase()) { - case 'AUTO': - case 'HIDDEN': - case 'SCROLL': - result.push(parentEl); - break; - default: - break; - } - } - - parentEl = parentEl.parentNode; - } - return result; - } - - private getMenuEl(dropdownEl: ElementRef): HTMLElement { - return dropdownEl.nativeElement.querySelector('.sky-dropdown-menu'); - } - - private getButtonEl(dropdownEl: ElementRef): HTMLElement { - return dropdownEl.nativeElement.querySelector('.sky-dropdown-button'); - } -} diff --git a/src/modules/dropdown/dropdown-item.component.html b/src/modules/dropdown/dropdown-item.component.html index 5c7a9a544..1a2b3b0c6 100644 --- a/src/modules/dropdown/dropdown-item.component.html +++ b/src/modules/dropdown/dropdown-item.component.html @@ -1,3 +1,9 @@ -
    - +
    + +
    diff --git a/src/modules/dropdown/dropdown-item.component.ts b/src/modules/dropdown/dropdown-item.component.ts index bcd4f5760..e919e07d3 100644 --- a/src/modules/dropdown/dropdown-item.component.ts +++ b/src/modules/dropdown/dropdown-item.component.ts @@ -1,8 +1,55 @@ -import { Component } from '@angular/core'; +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef +} from '@angular/core'; @Component({ selector: 'sky-dropdown-item', templateUrl: './dropdown-item.component.html', - styleUrls: ['./dropdown-item.component.scss'] + styleUrls: ['./dropdown-item.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush }) -export class SkyDropdownItemComponent { } +export class SkyDropdownItemComponent implements AfterViewInit { + public isActive = false; + public isDisabled = false; + public buttonElement: HTMLButtonElement; + + public constructor( + public elementRef: ElementRef, + private changeDetector: ChangeDetectorRef + ) { } + + public ngAfterViewInit() { + this.buttonElement = this.elementRef.nativeElement.querySelector('button'); + this.isDisabled = !this.isFocusable(); + this.changeDetector.detectChanges(); + } + + public focusElement(enableNativeFocus: boolean) { + this.isActive = true; + + if (enableNativeFocus) { + this.buttonElement.focus(); + } + + this.changeDetector.detectChanges(); + } + + public isFocusable(): boolean { + /*tslint:disable no-null-keyword */ + const isFocusable = ( + this.buttonElement && + this.buttonElement.getAttribute('disabled') === null + ); + /*tslint:enable */ + return isFocusable; + } + + public resetState() { + this.isActive = false; + this.changeDetector.markForCheck(); + } +} diff --git a/src/modules/dropdown/dropdown-menu.component.html b/src/modules/dropdown/dropdown-menu.component.html new file mode 100644 index 000000000..20e6fd415 --- /dev/null +++ b/src/modules/dropdown/dropdown-menu.component.html @@ -0,0 +1,4 @@ +
    + + +
    diff --git a/src/modules/dropdown/dropdown-menu.component.scss b/src/modules/dropdown/dropdown-menu.component.scss index ad600bcad..05add633a 100644 --- a/src/modules/dropdown/dropdown-menu.component.scss +++ b/src/modules/dropdown/dropdown-menu.component.scss @@ -1 +1,11 @@ -@import "../../scss/mixins"; +@import '../../scss/mixins'; + +.sky-dropdown-menu { + display: flex; + flex-direction: column; + + ::ng-deep button { + overflow: hidden; + text-overflow: ellipsis; + } +} diff --git a/src/modules/dropdown/dropdown-menu.component.ts b/src/modules/dropdown/dropdown-menu.component.ts index c5b173546..71e2ba88a 100644 --- a/src/modules/dropdown/dropdown-menu.component.ts +++ b/src/modules/dropdown/dropdown-menu.component.ts @@ -1,8 +1,206 @@ -import { Component } from '@angular/core'; +import { + AfterContentInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ContentChildren, + EventEmitter, + HostListener, + Input, + OnDestroy, + Output, + QueryList +} from '@angular/core'; + +import { Subject } from 'rxjs/Subject'; + +import { SkyDropdownItemComponent } from './dropdown-item.component'; + +import { + SkyDropdownMenuChange +} from './types'; @Component({ selector: 'sky-dropdown-menu', - templateUrl: '../shared/simple-content.html', - styleUrls: ['./dropdown-menu.component.scss'] + templateUrl: './dropdown-menu.component.html', + styleUrls: ['./dropdown-menu.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush }) -export class SkyDropdownMenuComponent { } +export class SkyDropdownMenuComponent implements AfterContentInit, OnDestroy { + @Input() + public useNativeFocus = true; + + @Output() + public menuChanges = new EventEmitter(); + + public get menuIndex(): number { + return this._menuIndex; + } + + public set menuIndex(value: number) { + if (value < 0) { + value = this.menuItems.length - 1; + } + + if (value >= this.menuItems.length) { + value = 0; + } + + this._menuIndex = value; + } + + @ContentChildren(SkyDropdownItemComponent) + public menuItems: QueryList; + + private destroy = new Subject(); + private get hasFocusableItems(): boolean { + const found = this.menuItems.find(item => item.isFocusable()); + return (found !== undefined); + } + + private _menuIndex = 0; + + constructor( + private changeDetector: ChangeDetectorRef + ) { } + + public ngAfterContentInit() { + // Reset focus whenever the menu items change. + this.menuItems.changes + .takeUntil(this.destroy) + .subscribe(() => { + this.menuIndex = 0; + this.focusActiveItem(); + this.menuChanges.emit({ + activeIndex: this.menuIndex + }); + }); + } + + public ngOnDestroy() { + this.destroy.next(true); + this.destroy.unsubscribe(); + } + + @HostListener('click', ['$event']) + public onClick(event: MouseEvent) { + const selectedItem = this.menuItems.find((item: SkyDropdownItemComponent) => { + return (item.elementRef.nativeElement.contains(event.target)); + }); + + /* istanbul ignore else */ + if (selectedItem) { + this.menuChanges.next({ + selectedItem + }); + } + } + + @HostListener('focusin', ['$event']) + public onFocusIn(event: KeyboardEvent) { + this.menuItems.forEach((item: SkyDropdownItemComponent, i: number) => { + item.resetState(); + + if (item.elementRef.nativeElement.contains(event.target)) { + this.menuIndex = i; + item.isActive = true; + this.menuChanges.emit({ + activeIndex: this.menuIndex + }); + } + }); + } + + @HostListener('keydown', ['$event']) + public onKeyDown(event: KeyboardEvent) { + const key = event.key.toLowerCase(); + + if (key === 'arrowdown') { + this.focusNextItem(); + event.preventDefault(); + } + + if (key === 'arrowup') { + this.focusPreviousItem(); + event.preventDefault(); + } + } + + public focusFirstItem() { + if (!this.hasFocusableItems) { + return; + } + + this.menuIndex = 0; + + const firstItem = this.getItemByIndex(this.menuIndex); + if (firstItem && firstItem.isFocusable()) { + this.focusItem(firstItem); + } else { + this.focusNextItem(); + } + } + + public focusPreviousItem() { + if (!this.hasFocusableItems) { + return; + } + + this.menuIndex--; + + const previousItem = this.getItemByIndex(this.menuIndex); + if (previousItem && previousItem.isFocusable()) { + this.focusItem(previousItem); + } else { + this.focusPreviousItem(); + } + } + + public focusNextItem() { + if (!this.hasFocusableItems) { + return; + } + + this.menuIndex++; + + const nextItem = this.getItemByIndex(this.menuIndex); + if (nextItem && nextItem.isFocusable()) { + this.focusItem(nextItem); + } else { + this.focusNextItem(); + } + } + + public reset() { + this._menuIndex = -1; + this.resetItemsActiveState(); + this.changeDetector.markForCheck(); + } + + private resetItemsActiveState() { + this.menuItems.forEach((item: SkyDropdownItemComponent) => { + item.resetState(); + }); + } + + private focusActiveItem() { + const activeItem = this.getItemByIndex(this.menuIndex); + if (activeItem) { + this.focusItem(activeItem); + } + } + + private focusItem(item: SkyDropdownItemComponent) { + this.resetItemsActiveState(); + item.focusElement(this.useNativeFocus); + this.menuChanges.emit({ + activeIndex: this.menuIndex + }); + } + + private getItemByIndex(index: number) { + return this.menuItems.find((item: any, i: number) => { + return (i === index); + }); + } +} diff --git a/src/modules/dropdown/dropdown.component.html b/src/modules/dropdown/dropdown.component.html index 1f516b22d..969c68889 100644 --- a/src/modules/dropdown/dropdown.component.html +++ b/src/modules/dropdown/dropdown.component.html @@ -1,42 +1,56 @@ -
    +
    -
    - -
    + + + +
    diff --git a/src/modules/dropdown/dropdown.component.scss b/src/modules/dropdown/dropdown.component.scss index 22efc146f..6df1c9a6e 100644 --- a/src/modules/dropdown/dropdown.component.scss +++ b/src/modules/dropdown/dropdown.component.scss @@ -1,4 +1,25 @@ -@import "../../scss/mixins"; +@import '../../scss/mixins'; + +.sky-dropdown { + ::ng-deep .sky-popover-container { + padding: 0; + min-width: auto; + max-width: none; + + .sky-popover { + border-radius: 0 !important; + border: 0 !important; + } + + .sky-popover-body { + padding: 0; + } + + .sky-popover-arrow { + display: none; + } + } +} .sky-dropdown-button-type-tab { @include sky-btn-tab; @@ -18,18 +39,6 @@ margin-left: $sky-margin; } -.sky-dropdown-menu { - background-color: #fff; - display: none; - position: fixed; - @include sky-shadow; - z-index: $sky-dropdown-z-index; - - &.sky-dropdown-open { - display: block; - } -} - .sky-dropdown-button-container { display: flex; } @@ -43,10 +52,3 @@ .sky-dropdown-button-icon-container { flex-grow: 1; } - -.sky-dropdown-menu { - /deep/ button { - overflow: hidden; - text-overflow: ellipsis; - } -} diff --git a/src/modules/dropdown/dropdown.component.spec.ts b/src/modules/dropdown/dropdown.component.spec.ts index 48b8e21d7..0c5db46fd 100644 --- a/src/modules/dropdown/dropdown.component.spec.ts +++ b/src/modules/dropdown/dropdown.component.spec.ts @@ -1,614 +1,589 @@ import { - TestBed + async, + ComponentFixture, + fakeAsync, + TestBed, + tick } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; +import { + expect, + TestUtility +} from '../testing'; + +import { + SkyDropdownMessageType +} from './types'; -import { DropdownTestComponent } from './fixtures/dropdown.component.fixture'; -import { DropdownParentTestComponent } from './fixtures/dropdown-parent.component.fixture'; import { SkyDropdownFixturesModule } from './fixtures/dropdown-fixtures.module'; +import { DropdownTestComponent } from './fixtures/dropdown.component.fixture'; -import { TestUtility } from '../testing/testutility'; -import { expect } from '../testing'; +describe('Dropdown component', () => { + const activeItemClass = 'sky-dropdown-item-active'; + let fixture: ComponentFixture; + let component: DropdownTestComponent; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + SkyDropdownFixturesModule + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DropdownTestComponent); + component = fixture.componentInstance; + }); -import { SkyWindowRefService } from '../window'; + function openPopoverWithButtonClick() { + tick(); + fixture.detectChanges(); -describe('Dropdown component', () => { + const buttonElem = getDropdownButtonElement(); + + verifyMenuVisibility(false); - function getDropdownEl(el: Element) { - return el.querySelector('.sky-dropdown'); + buttonElem.click(); + tick(); + fixture.detectChanges(); + tick(); + + verifyMenuVisibility(); } - function getDropdownBtnEl(el: Element) { - return el.querySelector('.sky-dropdown-button'); + // Simulates a click event on a button (which also registers the Enter key). + function dispatchKeyboardButtonClickEvent(elem: HTMLElement) { + TestUtility.fireKeyboardEvent(elem, 'keydown', { key: 'Enter' }); + elem.click(); } - function getDropdownMenuEl(el: Element) { - return el.querySelector('.sky-dropdown-menu'); + function verifyMenuVisibility(isVisible = true) { + const popoverElem = getPopoverContainerElement(); + expect(isElementVisible(popoverElem)).toEqual(isVisible); + expect(component.dropdown['isOpen']).toEqual(isVisible); } - describe('parent element tests', () => { - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ - SkyDropdownFixturesModule - ] - }); + function verifyActiveMenuItemByIndex(index: number) { + const menuItems = component.dropdown['menuComponent']['menuItems'].toArray(); + + menuItems.forEach((item: any, i: number) => { + if (i === index) { + expect(menuItems[i].isActive).toEqual(true); + expect(menuItems[i].elementRef.nativeElement.querySelector('.sky-dropdown-item')) + .toHaveCssClass(activeItemClass); + } else { + expect(menuItems[i].isActive).toEqual(false); + expect(menuItems[i].elementRef.nativeElement.querySelector('.sky-dropdown-item')) + .not.toHaveCssClass(activeItemClass); + } }); + } - it('should close dropdown on scroll events', () => { - - let fixture = TestBed.createComponent(DropdownParentTestComponent); - let el: HTMLElement = fixture.nativeElement; - - fixture.detectChanges(); + function verifyFocusedMenuItemByIndex(index: number, isFocused = true) { + const menuItems = getDropdownItemElements(); + expect(isElementFocused(menuItems.item(index).querySelector('button'))).toEqual(isFocused); + } - let parent1El = fixture.debugElement.query(By.css('#parent-1')); + function verifyTriggerButtonHasFocus(hasFocus = true) { + const buttonElem = getDropdownButtonElement(); + const buttonHasFocus = isElementFocused(buttonElem); + expect(buttonHasFocus).toEqual(hasFocus); + } - let dropdown1BtnEl = el.querySelector('#dropdown-1 .sky-dropdown-button') as HTMLElement; + function getDropdownHostElement(): HTMLElement { + return fixture.nativeElement.querySelector('sky-dropdown') as HTMLElement; + } - dropdown1BtnEl.click(); + function getDropdownMenuHostElement(): HTMLElement { + return fixture.nativeElement.querySelector('sky-dropdown-menu') as HTMLElement; + } - fixture.detectChanges(); + function getDropdownButtonElement(): HTMLElement { + return fixture.nativeElement.querySelector('.sky-dropdown-button') as HTMLElement; + } - parent1El.triggerEventHandler('scroll', {}); - let dropdownMenu1 = el.querySelector('#dropdown-1 .sky-dropdown-menu') as HTMLElement; + function getPopoverContainerElement(): HTMLElement { + return fixture.nativeElement.querySelector('.sky-popover-container') as HTMLElement; + } - expect(dropdownMenu1).not.toBeVisible(); - }); + function getDropdownItemElements(): NodeListOf { + return getPopoverContainerElement().querySelectorAll('.sky-dropdown-item'); + } - it('should close dropdown on window scroll', () => { + function isElementFocused(elem: Element): boolean { + return (elem === document.activeElement); + } - let fixture = TestBed.createComponent(DropdownParentTestComponent); - let el: HTMLElement = fixture.nativeElement; + function isElementVisible(elem: HTMLElement): boolean { + return (getComputedStyle(elem).visibility !== 'hidden'); + } + describe('basic setup', () => { + it('should have a default button type of "select"', () => { fixture.detectChanges(); + const buttonElem = getDropdownButtonElement(); + expect(buttonElem).toHaveCssClass('sky-dropdown-button-type-select'); + expect(buttonElem.innerText.trim()).toBe('Show dropdown'); + expect(buttonElem.querySelector('.sky-dropdown-caret')).not.toBeNull(); + }); - let dropdown1BtnEl = el.querySelector('#dropdown-1 .sky-dropdown-button') as HTMLElement; - - dropdown1BtnEl.click(); + it('should set the correct button type CSS class', () => { + const buttonElem = getDropdownButtonElement(); + component.buttonType = 'foobar'; fixture.detectChanges(); + expect(buttonElem).toHaveCssClass('sky-dropdown-button-type-foobar'); + expect(buttonElem.innerText.trim()).toBe(''); + expect(buttonElem.querySelector('.sky-dropdown-caret')).toBeNull(); + expect(buttonElem.querySelector('.fa-foobar')).not.toBeNull(); + }); - let dropdown3BtnEl = el.querySelector('#dropdown-3 .sky-dropdown-button') as HTMLElement; - dropdown3BtnEl.click(); + it('should accept button type of "context-menu"', () => { + component.buttonType = 'context-menu'; fixture.detectChanges(); - - let windowScrollEvt = document.createEvent('CustomEvent'); - windowScrollEvt.initEvent('scroll', false, false); - - window.dispatchEvent(windowScrollEvt); - - let dropdownMenu3 = el.querySelector('#dropdown-3 .sky-dropdown-menu') as HTMLElement; - - expect(dropdownMenu3).not.toBeVisible(); + const buttonElem = getDropdownButtonElement(); + expect(buttonElem).toHaveCssClass('sky-dropdown-button-type-context-menu'); + expect(buttonElem.innerText.trim()).toBe(''); + expect(buttonElem.querySelector('.sky-dropdown-caret')).toBeNull(); }); - it('should close dropdown on multiple parent scroll', () => { - - let fixture = TestBed.createComponent(DropdownParentTestComponent); - let el: HTMLElement = fixture.nativeElement; - + it('should have a default button background of "sky-btn-default"', () => { + const buttonElem = getDropdownButtonElement(); fixture.detectChanges(); + expect(buttonElem).toHaveCssClass('sky-btn-default'); + }); - let dropdown1BtnEl = el.querySelector('#dropdown-1 .sky-dropdown-button') as HTMLElement; - - dropdown1BtnEl.click(); + it('should set the CSS class based on buttonStyle changes', () => { + const buttonElem = getDropdownButtonElement(); + component.buttonStyle = 'primary'; fixture.detectChanges(); - - let windowScrollEvt = document.createEvent('CustomEvent'); - windowScrollEvt.initEvent('scroll', false, false); - - window.dispatchEvent(windowScrollEvt); - - let dropdownMenu1 = el.querySelector('#dropdown-1 .sky-dropdown-menu') as HTMLElement; - - expect(dropdownMenu1).not.toBeVisible(); + expect(buttonElem).toHaveCssClass('sky-btn-primary'); }); - it('should display default label when label not set', () => { - - let fixture = TestBed.createComponent(DropdownParentTestComponent); - let el: HTMLElement = fixture.nativeElement; - + it('should set the correct title when specified', () => { + const buttonElem = getDropdownButtonElement(); + component.title = 'Dropdown title'; fixture.detectChanges(); + expect(buttonElem.getAttribute('title')).toBe('Dropdown title'); + }); - let button = el.querySelector('#dropdown-1 .sky-dropdown-button') as HTMLButtonElement; - let label = button.getAttribute('aria-label'); - + it('should display default label when label not set', () => { + fixture.detectChanges(); + const buttonElem = getDropdownButtonElement(); + const label = buttonElem.getAttribute('aria-label'); expect(label).toBe('Context menu'); }); - it('should display default label when label is set', () => { - - let fixture = TestBed.createComponent(DropdownParentTestComponent); - let el: HTMLElement = fixture.nativeElement; - + it('should display label when label is set', () => { + const buttonElem = getDropdownButtonElement(); + component.label = 'test label'; fixture.detectChanges(); - - let button = el.querySelector('#dropdown-4 .sky-dropdown-button') as HTMLButtonElement; - let label = button.getAttribute('aria-label'); - + const label = buttonElem.getAttribute('aria-label'); expect(label).toBe('test label'); }); - }); - describe('postition tests', () => { - - class MockWindowService { - - public innerHeight: number = 100; - public innerWidth: number = 500; - public getWindow() { - return { - innerHeight: this.innerHeight, - innerWidth: this.innerWidth, - getComputedStyle(element: HTMLElement, obj: any) { - return { - overflowY: 'auto' - }; - } - }; - } - } - let mockWindowService = new MockWindowService(); - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ - SkyDropdownFixturesModule - ], - providers: [ - { - provide: SkyWindowRefService, - useValue: mockWindowService - } - ] - }); + it('should map the trigger type to the popover trigger type', () => { + component.trigger = 'hover'; + fixture.detectChanges(); + const popoverTriggerType = component.dropdown.getPopoverTriggerType(); + expect(popoverTriggerType).toEqual('mouseenter'); }); + }); - it('should display dropdown above when necessary', () => { - - mockWindowService.innerHeight = 100; - mockWindowService.innerWidth = 500; - - let fixture = TestBed.createComponent(DropdownTestComponent); - let el: HTMLElement = fixture.nativeElement; + describe('click interactions', () => { + it('should open the menu when clicking the trigger button', fakeAsync(() => { + openPopoverWithButtonClick(); + })); + }); - el.style.position = 'absolute'; - el.style.top = '100px'; + describe('keyboard interactions', () => { + it('should close the dropdown and focus the trigger button after user presses the esc key', fakeAsync(() => { + openPopoverWithButtonClick(); - fixture.detectChanges(); - let dropdownBtnEl = getDropdownBtnEl(el); - - dropdownBtnEl.click(); + const popoverElem = getPopoverContainerElement(); + TestUtility.fireKeyboardEvent(popoverElem, 'keydown', { key: 'Escape' }); + TestUtility.fireKeyboardEvent(popoverElem, 'keyup', { key: 'Escape' }); + tick(); fixture.detectChanges(); + tick(); - let menuEl = el.querySelector('.sky-dropdown-menu') as HTMLElement; - let topValue = menuEl.style.top; + verifyMenuVisibility(false); + verifyTriggerButtonHasFocus(); + })); - expect(parseInt(topValue, 10) < 100).toBe(true); - }); + it('should close the dropdown after menu loses focus', fakeAsync(() => { + openPopoverWithButtonClick(); - it('should display dropdown center when necessary', () => { + const dropdownHost = getDropdownHostElement(); + const dropdownItems = dropdownHost.querySelectorAll('.sky-dropdown-item'); + const firstItem = dropdownItems.item(0); - mockWindowService.innerHeight = 40; - mockWindowService.innerWidth = 100; - - let fixture = TestBed.createComponent(DropdownTestComponent); - let el: HTMLElement = fixture.nativeElement; - - el.style.position = 'absolute'; - el.style.top = '10px'; - el.style.left = '50px'; - let menuEl = el.querySelector('.sky-dropdown-menu') as HTMLElement; + // Should not close the dropdown if focus remains in dropdown. + TestUtility.fireKeyboardEvent(firstItem.querySelector('button'), 'focusin'); + tick(); + fixture.detectChanges(); + tick(); - menuEl.style.width = '98px'; + verifyMenuVisibility(true); + TestUtility.fireKeyboardEvent(component.outsideButton.nativeElement, 'focusin'); + tick(); fixture.detectChanges(); - let dropdownBtnEl = getDropdownBtnEl(el); + tick(); - dropdownBtnEl.click(); + verifyMenuVisibility(false); + verifyTriggerButtonHasFocus(false); + })); + it('should open menu if arrow down is pressed', fakeAsync(() => { + tick(); fixture.detectChanges(); - let leftValue = menuEl.style.left; + const hostElem = getDropdownHostElement(); - expect(parseInt(leftValue, 10) < 50).toBe(true); - }); + verifyMenuVisibility(false); - it('should try the opposite alignment', () => { - mockWindowService.innerHeight = 40; - mockWindowService.innerWidth = 100; + TestUtility.fireKeyboardEvent(hostElem, 'keydown', { key: 'arrowdown' }); + tick(); + fixture.detectChanges(); + tick(); - let fixture = TestBed.createComponent(DropdownTestComponent); - fixture.componentInstance.alignment = 'right'; - let el: HTMLElement = fixture.nativeElement; + verifyMenuVisibility(); + })); - el.style.position = 'absolute'; - el.style.top = '10px'; - el.style.left = '0px'; - el.style.width = '50px'; - let menuEl = el.querySelector('.sky-dropdown-menu') as HTMLElement; + it('should navigate menu items with arrow keys', fakeAsync(() => { + openPopoverWithButtonClick(); - menuEl.style.width = '98px'; + const hostElem = getDropdownMenuHostElement(); + TestUtility.fireKeyboardEvent(hostElem, 'keydown', { key: 'arrowdown' }); + tick(); fixture.detectChanges(); - let dropdownBtnEl = getDropdownBtnEl(el); + tick(); - dropdownBtnEl.click(); + verifyActiveMenuItemByIndex(0); + verifyFocusedMenuItemByIndex(0); + TestUtility.fireKeyboardEvent(hostElem, 'keydown', { key: 'arrowdown' }); + tick(); fixture.detectChanges(); + tick(); - let leftValue = menuEl.style.left; - - expect(leftValue).toBe('0px'); - }); - - it('should fallback to position 10, 10 and take screen width when nothing else works', () => { - mockWindowService.innerHeight = 30; - mockWindowService.innerWidth = 100; - - let fixture = TestBed.createComponent(DropdownTestComponent); - let el: HTMLElement = fixture.nativeElement; - - el.style.position = 'absolute'; - el.style.top = '10px'; - el.style.left = '50px'; - - let menuEl = el.querySelector('.sky-dropdown-menu') as HTMLElement; - - menuEl.style.width = '101px'; + // The second item is disabled, so it should be skipped! + verifyActiveMenuItemByIndex(2); + verifyFocusedMenuItemByIndex(2); + TestUtility.fireKeyboardEvent(hostElem, 'keydown', { key: 'arrowup' }); + tick(); fixture.detectChanges(); - let dropdownBtnEl = getDropdownBtnEl(el); + tick(); - dropdownBtnEl.click(); + // The second item is disabled, so it should be skipped! + verifyActiveMenuItemByIndex(0); + verifyFocusedMenuItemByIndex(0); + // Navigation should loop from the last item to the first: + TestUtility.fireKeyboardEvent(hostElem, 'keydown', { key: 'arrowdown' }); + TestUtility.fireKeyboardEvent(hostElem, 'keydown', { key: 'arrowdown' }); + TestUtility.fireKeyboardEvent(hostElem, 'keydown', { key: 'arrowdown' }); + tick(); fixture.detectChanges(); + tick(); - let leftValue = menuEl.style.left; - let topValue = menuEl.style.top; - let width = menuEl.style.width; - let height = menuEl.style.height; - let maxWidth = menuEl.style.maxWidth; - let maxHeight = menuEl.style.maxHeight; - let overflowY = menuEl.style.overflowY; - let overflowX = menuEl.style.overflowX; + verifyActiveMenuItemByIndex(0); + verifyFocusedMenuItemByIndex(0); - expect(parseInt(leftValue, 10)).toBe(10); - expect(parseInt(topValue, 10)).toBe(10); - expect(parseInt(width, 10)).toBe(80); - expect(parseInt(height, 10)).toBe(10); - expect(parseInt(maxWidth, 10)).toBe(80); - expect(parseInt(maxHeight, 10)).toBe(10); - expect(overflowY).toBe('auto'); - expect(overflowX).toBe('auto'); + TestUtility.fireKeyboardEvent(hostElem, 'keydown', { key: 'arrowup' }); + tick(); + fixture.detectChanges(); + tick(); - expect(document.body).toHaveCssClass('sky-dropdown-no-scroll'); - }); - }); + verifyActiveMenuItemByIndex(3); + verifyFocusedMenuItemByIndex(3); + })); - describe('vanilla setup', () => { - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ - SkyDropdownFixturesModule - ] - }); - }); + it('should focus the first item if opened with enter key', fakeAsync(() => { + tick(); + fixture.detectChanges(); - it('should handle right alignment when width of menu is larger than trigger', () => { - let fixture = TestBed.createComponent(DropdownTestComponent); - let el: HTMLElement = fixture.nativeElement; + const buttonElem = getDropdownButtonElement(); + const spy = spyOn(component.dropdown['menuComponent'], 'focusFirstItem').and.callThrough(); - fixture.componentInstance.alignment = 'right'; - el.style.position = 'absolute'; - el.style.left = '100px'; + verifyMenuVisibility(false); + dispatchKeyboardButtonClickEvent(buttonElem); + tick(); fixture.detectChanges(); - - let dropdownButton = getDropdownBtnEl(el); - dropdownButton.click(); + tick(); fixture.detectChanges(); - let menuEl = getDropdownMenuEl(el); - - expect(parseInt(menuEl.style.left, 10) < 100).toBe(true); - }); - - it('should handle right alignment when width of menu is smaller than trigger', () => { - let fixture = TestBed.createComponent(DropdownTestComponent); - let el: HTMLElement = fixture.nativeElement; - - fixture.componentInstance.alignment = 'right'; - el.style.position = 'absolute'; - el.style.left = '100px'; + verifyMenuVisibility(); + verifyActiveMenuItemByIndex(0); + expect(spy).toHaveBeenCalled(); + })); + it('should not focus the first item if it is disabled', fakeAsync(() => { + tick(); fixture.detectChanges(); - let dropdownButton = getDropdownBtnEl(el); - - dropdownButton.style.minWidth = '300px'; - dropdownButton.click(); + const buttonElem = getDropdownButtonElement(); + component.setItems([ + { name: 'Foo', disabled: true }, + { name: 'Bar', disabled: false } + ]); fixture.detectChanges(); - let menuEl = getDropdownMenuEl(el); + verifyMenuVisibility(false); - expect(parseInt(menuEl.style.left, 10) > 100).toBe(true); - }); - - it('should have a default button type of "select"', () => { - let fixture = TestBed.createComponent(DropdownTestComponent); - let el: Element = fixture.nativeElement; + const firstSpy = spyOn(component.dropdown['menuComponent']['menuItems'].first, 'focusElement').and.callThrough(); + const lastSpy = spyOn(component.dropdown['menuComponent']['menuItems'].last, 'focusElement').and.callThrough(); + dispatchKeyboardButtonClickEvent(buttonElem); + tick(); fixture.detectChanges(); - - expect(getDropdownBtnEl(el)).toHaveCssClass('sky-dropdown-button-type-select'); - }); - - it('should set the correct button type CSS class', () => { - let fixture = TestBed.createComponent(DropdownTestComponent); - let cmp = fixture.componentInstance; - let el: Element = fixture.nativeElement; - - cmp.buttonType = 'context-menu'; - + tick(); + + verifyMenuVisibility(); + expect(firstSpy).not.toHaveBeenCalled(); + expect(lastSpy).toHaveBeenCalled(); + verifyFocusedMenuItemByIndex(0, false); + })); + + it('should handle focusing the first item when all items are disabled', fakeAsync(() => { + const buttonElem = getDropdownButtonElement(); + component.setItems([ + { name: 'Foo', disabled: true } + ]); fixture.detectChanges(); - expect(getDropdownBtnEl(el)).toHaveCssClass('sky-dropdown-button-type-context-menu'); - }); + verifyMenuVisibility(false); - it('should have a default button background of "sky-btn-default"', () => { - let fixture = TestBed.createComponent(DropdownTestComponent); - let el: Element = fixture.nativeElement; + const firstSpy = spyOn(component.dropdown['menuComponent']['menuItems'].first, 'focusElement').and.callThrough(); + dispatchKeyboardButtonClickEvent(buttonElem); + tick(); fixture.detectChanges(); + tick(); - expect(getDropdownBtnEl(el)).toHaveCssClass('sky-btn-default'); - }); - - it('should set the CSS class based on buttonStyle changes', () => { - let fixture = TestBed.createComponent(DropdownTestComponent); - let cmp = fixture.componentInstance; - let el: Element = fixture.nativeElement; - - cmp.buttonStyle = 'primary'; + verifyMenuVisibility(); + expect(firstSpy).not.toHaveBeenCalled(); + })); + it('should handle focusing when no items are present', () => { + component.setItems([]); fixture.detectChanges(); - - expect(getDropdownBtnEl(el)).toHaveCssClass('sky-btn-primary'); + const spy = spyOn(component.dropdown['menuComponent'] as any, 'focusItem').and.callThrough(); + component.dropdown['menuComponent']['focusActiveItem'](); + expect(spy).not.toHaveBeenCalled(); }); - it('should set the correct title when specified', () => { - let fixture = TestBed.createComponent(DropdownTestComponent); - let cmp = fixture.componentInstance; - let el: Element = fixture.nativeElement; + it('should handle focusing when item does not include a button', fakeAsync(() => { + component.setItems([ + { name: 'Foo', disabled: false } + ]); - cmp.myTitle = 'dropdown title'; + const menuItemComponent = component.dropdown['menuComponent']['menuItems'].first; + spyOn(menuItemComponent.elementRef.nativeElement, 'querySelector').and.returnValue(undefined); + fixture.detectChanges(); + menuItemComponent.ngAfterViewInit(); + tick(); fixture.detectChanges(); - expect(getDropdownBtnEl(el).getAttribute('title')).toBe('dropdown title'); - }); - - describe('with trigger type "click"', () => { - it('should open the dropdown menu when clicking the dropdown button', () => { - let fixture = TestBed.createComponent(DropdownTestComponent); - let cmp = fixture.componentInstance; - let el = fixture.nativeElement; - - cmp.buttonType = 'context-menu'; - - fixture.detectChanges(); - - let dropdownBtnEl = getDropdownBtnEl(el); - - dropdownBtnEl.click(); - - expect(getDropdownMenuEl(el)).toBeVisible(); - }); - - it('should close the dropdown menu when clicking outside it', () => { - let fixture = TestBed.createComponent(DropdownTestComponent); - let cmp = fixture.componentInstance; - let el = fixture.nativeElement; - - cmp.buttonType = 'context-menu'; - - fixture.detectChanges(); - - let dropdownBtnEl = getDropdownBtnEl(el); - - dropdownBtnEl.click(); - - let dropdownMenuEl = getDropdownMenuEl(el); - expect(dropdownMenuEl).toBeVisible(); - - TestUtility.fireDomEvent(document, 'click'); - - fixture.detectChanges(); - - expect(dropdownMenuEl).not.toBeVisible(); - }); - - it('should close the dropdown menu when clicking the button a second time', () => { - let fixture = TestBed.createComponent(DropdownTestComponent); - let cmp = fixture.componentInstance; - let el = fixture.nativeElement; - - cmp.buttonType = 'context-menu'; - - fixture.detectChanges(); - - let dropdownBtnEl = getDropdownBtnEl(el); - dropdownBtnEl.click(); - - let dropdownMenuEl = getDropdownMenuEl(el); - expect(dropdownMenuEl).toBeVisible(); - - dropdownBtnEl.click(); - - fixture.detectChanges(); - - expect(dropdownMenuEl).not.toBeVisible(); - }); + expect(menuItemComponent.isDisabled).toEqual(true); + })); - it('should not open the dropdown menu when the mouse enters the dropdown button', () => { - let fixture = TestBed.createComponent(DropdownTestComponent); - let cmp = fixture.componentInstance; - let el = fixture.nativeElement; + it('should return focus to the trigger button after making a selection with enter key', fakeAsync(() => { + tick(); + fixture.detectChanges(); - cmp.buttonType = 'context-menu'; + const buttonElem = getDropdownButtonElement(); + const popoverElem = getPopoverContainerElement(); - fixture.detectChanges(); + verifyMenuVisibility(false); - let dropdownEl = getDropdownEl(el); - TestUtility.fireDomEvent(dropdownEl, 'mouseenter'); + dispatchKeyboardButtonClickEvent(buttonElem); + tick(); + fixture.detectChanges(); + tick(); - fixture.detectChanges(); + verifyMenuVisibility(); - expect(getDropdownMenuEl(el)).not.toBeVisible(); - }); - - it('should close the dropdown menu when moving the mouse outside the menu', () => { - let fixture = TestBed.createComponent(DropdownTestComponent); - let cmp = fixture.componentInstance; - let el = fixture.nativeElement; + const menuItemButton = popoverElem.querySelectorAll('button').item(0); + dispatchKeyboardButtonClickEvent(menuItemButton); + tick(); + fixture.detectChanges(); + tick(); - cmp.buttonType = 'context-menu'; + verifyMenuVisibility(false); + verifyTriggerButtonHasFocus(); + })); - fixture.detectChanges(); + it('should handle other keydown events', fakeAsync(() => { + tick(); + fixture.detectChanges(); - let dropdownBtnEl = getDropdownBtnEl(el); - dropdownBtnEl.click(); + const buttonElem = getDropdownButtonElement(); - let dropdownMenuEl = getDropdownMenuEl(el); - expect(dropdownMenuEl).toBeVisible(); + verifyMenuVisibility(false); - let dropdownEl = getDropdownEl(el); - TestUtility.fireDomEvent(dropdownEl, 'mouseleave'); + TestUtility.fireKeyboardEvent(buttonElem, 'keydown', { key: 'a' }); + tick(); + fixture.detectChanges(); + tick(); - fixture.detectChanges(); + verifyMenuVisibility(false); + })); - expect(dropdownMenuEl).toBeVisible(); - }); - }); + it('should allow disabling of native focus', fakeAsync(() => { + tick(); + fixture.detectChanges(); - describe('with trigger type "hover"', () => { - it('should open the dropdown menu when the mouse enters the dropdown button', () => { - let fixture = TestBed.createComponent(DropdownTestComponent); - let cmp = fixture.componentInstance; - let el = fixture.nativeElement; + const buttonElem = getDropdownButtonElement(); + component.dropdown['menuComponent'].useNativeFocus = false; + fixture.detectChanges(); - cmp.buttonType = 'context-menu'; - cmp.trigger = 'hover'; + verifyMenuVisibility(false); - fixture.detectChanges(); + buttonElem.click(); + tick(); + fixture.detectChanges(); + tick(); - let dropdownEl = getDropdownEl(el); - TestUtility.fireDomEvent(dropdownEl, 'mouseenter'); + verifyMenuVisibility(); - fixture.detectChanges(); + const itemComponent = component.dropdown['menuComponent']['menuItems'].first; + const focusSpy = spyOn(itemComponent['buttonElement'], 'focus'); - expect(getDropdownMenuEl(el)).toBeVisible(); - }); + TestUtility.fireKeyboardEvent(buttonElem, 'keydown', { key: 'arrowdown' }); + tick(); + fixture.detectChanges(); + tick(); - it('should close the dropdown menu when moving the mouse outside the menu', () => { - let fixture = TestBed.createComponent(DropdownTestComponent); - let cmp = fixture.componentInstance; - let el = fixture.nativeElement; + expect(focusSpy).not.toHaveBeenCalled(); + verifyActiveMenuItemByIndex(0); + verifyFocusedMenuItemByIndex(0, false); + })); + }); - cmp.buttonType = 'context-menu'; - cmp.trigger = 'hover'; + describe('message stream', () => { + it('should allow opening and closing the menu', fakeAsync(() => { + tick(); + fixture.detectChanges(); - fixture.detectChanges(); + component.sendMessage(SkyDropdownMessageType.Open); + tick(); + fixture.detectChanges(); + tick(); - let dropdownEl = getDropdownEl(el); - TestUtility.fireDomEvent(dropdownEl, 'mouseenter'); + verifyMenuVisibility(); - let dropdownMenuEl = getDropdownMenuEl(el); - expect(dropdownMenuEl).toBeVisible(); + component.sendMessage(SkyDropdownMessageType.Close); + tick(); + fixture.detectChanges(); + tick(); - TestUtility.fireDomEvent(dropdownEl, 'mouseleave'); + verifyMenuVisibility(false); + })); - fixture.detectChanges(); + it('should allow navigating the menu', fakeAsync(() => { + tick(); + fixture.detectChanges(); - expect(dropdownMenuEl).not.toBeVisible(); - }); + component.sendMessage(SkyDropdownMessageType.Open); + tick(); + fixture.detectChanges(); + tick(); - it('should close the dropdown menu when clicking the button', () => { - let fixture = TestBed.createComponent(DropdownTestComponent); - let cmp = fixture.componentInstance; - let el = fixture.nativeElement; + verifyMenuVisibility(); - cmp.buttonType = 'context-menu'; - cmp.trigger = 'hover'; + component.sendMessage(SkyDropdownMessageType.FocusNextItem); + tick(); + fixture.detectChanges(); + tick(); - fixture.detectChanges(); + verifyActiveMenuItemByIndex(0); + verifyFocusedMenuItemByIndex(0); - let dropdownEl = getDropdownEl(el); + component.sendMessage(SkyDropdownMessageType.FocusPreviousItem); + tick(); + fixture.detectChanges(); + tick(); - TestUtility.fireDomEvent(dropdownEl, 'mouseenter'); + verifyActiveMenuItemByIndex(3); + verifyFocusedMenuItemByIndex(3); + })); - let dropdownMenuEl = getDropdownMenuEl(el); - expect(dropdownMenuEl).toBeVisible(); + it('should disable navigation if all items are disabled', fakeAsync(() => { + component.setItems([ + { name: 'Foo', disabled: true } + ]); + fixture.detectChanges(); - let dropdownBtnEl = getDropdownBtnEl(el); - dropdownBtnEl.click(); + component.sendMessage(SkyDropdownMessageType.Open); + tick(); + fixture.detectChanges(); + tick(); - fixture.detectChanges(); + verifyMenuVisibility(); - expect(dropdownMenuEl).not.toBeVisible(); - }); - }); + component.sendMessage(SkyDropdownMessageType.FocusNextItem); + tick(); + fixture.detectChanges(); + tick(); - describe('of type "select"', () => { - it('should display an ellipsis instead of the specified button content', () => { - let fixture = TestBed.createComponent(DropdownTestComponent); - let el = fixture.nativeElement; + verifyFocusedMenuItemByIndex(0, false); - fixture.detectChanges(); + component.sendMessage(SkyDropdownMessageType.FocusPreviousItem); + tick(); + fixture.detectChanges(); + tick(); - let dropdownBtnEl = getDropdownBtnEl(el); + verifyFocusedMenuItemByIndex(0, false); + })); - expect(dropdownBtnEl.innerText.trim()).toBe('Show dropdown'); - expect(dropdownBtnEl.querySelector('.sky-dropdown-caret')).not.toBeNull(); - }); - }); + it('should allow focusing trigger button', fakeAsync(() => { + tick(); + fixture.detectChanges(); - describe('of type "context-menu"', () => { - it('should display an ellipsis instead of the specified button content', () => { - let fixture = TestBed.createComponent(DropdownTestComponent); - let cmp = fixture.componentInstance; - let el = fixture.nativeElement; + component.sendMessage(SkyDropdownMessageType.FocusTriggerButton); - cmp.buttonType = 'context-menu'; + tick(); + fixture.detectChanges(); + tick(); - fixture.detectChanges(); + verifyTriggerButtonHasFocus(); + })); + }); - let dropdownBtnEl = getDropdownBtnEl(el); + describe('menu changes', () => { + it('should reposition the menu when number of menu items change', fakeAsync(() => { + tick(); + fixture.detectChanges(); - expect(dropdownBtnEl).toHaveText(''); - expect(dropdownBtnEl.querySelector('.sky-dropdown-caret')).toBeNull(); - }); - }); + const buttonElem = getDropdownButtonElement(); + const spy = spyOn(component.dropdown.messageStream, 'next').and.callThrough(); - describe('of other types', () => { - it('should display an filter icon when that type is specified', () => { - let fixture = TestBed.createComponent(DropdownTestComponent); - let cmp = fixture.componentInstance; - let el = fixture.nativeElement; + verifyMenuVisibility(false); - cmp.buttonType = 'filter'; + buttonElem.click(); + tick(); + fixture.detectChanges(); + tick(); - fixture.detectChanges(); + verifyMenuVisibility(); + expect(getDropdownItemElements().length).toEqual(4); - let dropdownBtnEl = getDropdownBtnEl(el); + component.changeItems(); + tick(); + fixture.detectChanges(); + tick(); - expect(dropdownBtnEl).toHaveText(''); - expect(dropdownBtnEl.querySelector('.fa-filter')).not.toBeNull(); + expect(getDropdownItemElements().length).toEqual(3); + expect(spy).toHaveBeenCalledWith({ + type: SkyDropdownMessageType.Reposition }); - }); + })); }); }); diff --git a/src/modules/dropdown/dropdown.component.ts b/src/modules/dropdown/dropdown.component.ts index 91400e922..fdf5317d3 100644 --- a/src/modules/dropdown/dropdown.component.ts +++ b/src/modules/dropdown/dropdown.component.ts @@ -1,41 +1,67 @@ import { + AfterContentInit, + ChangeDetectionStrategy, Component, + ContentChild, ElementRef, + HostListener, Input, - Renderer, - OnDestroy + OnDestroy, + OnInit, + ViewChild } from '@angular/core'; -import { SkyDropdownAdapterService } from './dropdown-adapter.service'; +import { Subject } from 'rxjs/Subject'; -import { SkyWindowRefService } from '../window'; -import { SkyResources } from '../resources'; +import { + SkyPopoverAlignment, + SkyPopoverComponent, + SkyPopoverTrigger +} from '../popover'; + +import { + SkyResources +} from '../resources'; + +import { + SkyWindowRefService +} from '../window'; + +import { SkyDropdownMenuComponent } from './dropdown-menu.component'; + +import { + SkyDropdownMenuChange, + SkyDropdownMessage, + SkyDropdownMessageType, + SkyDropdownTriggerType +} from './types'; @Component({ selector: 'sky-dropdown', templateUrl: './dropdown.component.html', styleUrls: ['./dropdown.component.scss'], - providers: [ - SkyDropdownAdapterService - ] + changeDetection: ChangeDetectionStrategy.OnPush }) -export class SkyDropdownComponent implements OnDestroy { +export class SkyDropdownComponent implements OnInit, AfterContentInit, OnDestroy { @Input() - public set buttonType(value: string) { - this._buttonType = value; + public alignment: SkyPopoverAlignment = 'left'; + + @Input() + public get buttonStyle(): string{ + return this._buttonStyle || 'default'; } - public get buttonType(): string { - return this._buttonType || 'select'; + public set buttonStyle(value: string) { + this._buttonStyle = value; } @Input() - public set trigger(value: string) { - this._trigger = value; + public set buttonType(value: string) { + this._buttonType = value; } - public get trigger(): string { - return this._trigger || 'click'; + public get buttonType(): string { + return this._buttonType || 'select'; } @Input() @@ -48,96 +74,187 @@ export class SkyDropdownComponent implements OnDestroy { } @Input() - public title: string; + public dismissOnBlur = true; @Input() - public alignment: string = 'left'; + public messageStream = new Subject(); @Input() - public get buttonStyle(): string{ - return this._buttonStyle || 'default'; - } + public title: string; - public set buttonStyle(value: string) { - this._buttonStyle = value; + @Input() + public set trigger(value: SkyDropdownTriggerType) { + this._trigger = value; } - private open = false; + public get trigger(): SkyDropdownTriggerType { + return this._trigger || 'click'; + } - private opening = false; + @ViewChild('triggerButton') + private triggerButton: ElementRef; - private _buttonType: string; + @ViewChild(SkyPopoverComponent) + private popover: SkyPopoverComponent; - private _buttonStyle: string; + @ContentChild(SkyDropdownMenuComponent) + private menuComponent: SkyDropdownMenuComponent; - private _trigger: string; + private destroy = new Subject(); + private isKeyboardActive = false; + private isOpen = false; + private _buttonType: string; + private _buttonStyle: string; private _label: string; + private _trigger: SkyDropdownTriggerType; constructor( - private renderer: Renderer, - private elRef: ElementRef, - private adapterService: SkyDropdownAdapterService, - private windowObj: SkyWindowRefService - ) { - this.adapterService.dropdownClose.subscribe(() => { - this.open = false; - }); - } - - public click() { - this.openMenu(); - } - - public resetDropdownPosition() { - this.adapterService.setMenuLocation( - this.elRef, - this.renderer, - this.windowObj.getWindow(), - this.alignment - ); - } - - public windowClick() { - if (this.opening) { - this.opening = false; - this.open = true; - } else { - this.adapterService.hideDropdown(this.elRef, this.renderer, this.windowObj.getWindow()); - } + private windowRef: SkyWindowRefService + ) { } + + public ngOnInit() { + this.messageStream + .takeUntil(this.destroy) + .subscribe((message: SkyDropdownMessage) => { + this.handleIncomingMessages(message); + }); + } + + public ngAfterContentInit() { + this.menuComponent.menuItems.changes + .takeUntil(this.destroy) + .subscribe(() => { + // Update the popover's style and position whenever the number of items changes. + // e.g., If the menu is fullscreen, and removing an item allows it to fit + // as it should, we need to restore the popover's original styles. + // this.popover.updateClassNames(); + this.windowRef.getWindow().setTimeout(() => { + this.messageStream.next({ + type: SkyDropdownMessageType.Reposition + }); + }); + }); + + this.menuComponent.menuChanges + .takeUntil(this.destroy) + .subscribe((change: SkyDropdownMenuChange) => { + // Close the dropdown when a menu item is selected. + if (change.selectedItem) { + this.messageStream.next({ + type: SkyDropdownMessageType.Close + }); + } + }); + } + + public ngOnDestroy() { + this.destroy.next(true); + this.destroy.unsubscribe(); } - public mouseEnter() { - if (this.trigger === 'hover') { - this.openMenu(); - this.opening = false; - this.open = true; + @HostListener('keydown', ['$event']) + public onKeyDown(event: KeyboardEvent) { + const key = event.key.toLowerCase(); + + if (this.isOpen) { + /* tslint:disable:switch-default */ + switch (key) { + // After an item is selected with the enter key, + // wait a moment before returning focus to the dropdown trigger element. + case 'enter': + this.windowRef.getWindow().setTimeout(() => { + this.messageStream.next({ + type: SkyDropdownMessageType.FocusTriggerButton + }); + }); + break; + + // Allow the menu to be opened with the arrowdown key + // if it is first opened with the mouse. + case 'arrowdown': + if (!this.isKeyboardActive) { + this.isKeyboardActive = true; + this.menuComponent.focusFirstItem(); + event.preventDefault(); + } + break; + } + /* tslint:enable */ + + return; } + + /* tslint:disable:switch-default */ + switch (key) { + case 'enter': + this.isKeyboardActive = true; + break; + + case 'arrowdown': + this.isKeyboardActive = true; + this.messageStream.next({ + type: SkyDropdownMessageType.Open + }); + event.preventDefault(); + break; + } + /* tslint:enable */ } - public mouseLeave() { - if (this.trigger === 'hover') { - this.adapterService.hideDropdown(this.elRef, this.renderer, this.windowObj.getWindow()); + public onPopoverOpened() { + this.isOpen = true; + this.menuComponent.reset(); + // Focus the first item if the menu was opened with the keyboard. + if (this.isKeyboardActive) { + this.menuComponent.focusFirstItem(); } } - public ngOnDestroy() { - this.adapterService.hideDropdown(this.elRef, this.renderer, this.windowObj.getWindow()); + public onPopoverClosed() { + this.isOpen = false; + this.isKeyboardActive = false; + this.menuComponent.reset(); } - private openMenu() { - if (!this.open) { - this.adapterService.showDropdown( - this.elRef, - this.renderer, - this.windowObj.getWindow(), - this.alignment - ); + public getPopoverTriggerType(): SkyPopoverTrigger { + // Map the dropdown trigger type to the popover trigger type. + return (this.trigger === 'click') ? 'click' : 'mouseenter'; + } + + private handleIncomingMessages(message: SkyDropdownMessage) { + /* tslint:disable:switch-default */ + switch (message.type) { + case SkyDropdownMessageType.Open: + this.popover.positionNextTo(this.triggerButton, 'below', this.alignment); + break; + + case SkyDropdownMessageType.Close: + this.popover.close(); + break; + + case SkyDropdownMessageType.FocusTriggerButton: + this.triggerButton.nativeElement.focus(); + break; - // Notify the window click handler that the menu was just opened so it doesn't try to - // close it. - this.opening = true; + case SkyDropdownMessageType.FocusNextItem: + this.menuComponent.focusNextItem(); + break; + + case SkyDropdownMessageType.FocusPreviousItem: + this.menuComponent.focusPreviousItem(); + break; + + case SkyDropdownMessageType.Reposition: + this.popover.resetPopover(); + // Only reposition the dropdown if it is already open. + if (this.isOpen) { + this.messageStream.next({ + type: SkyDropdownMessageType.Open + }); + } + break; } + /* tslint:enable */ } - } diff --git a/src/modules/dropdown/dropdown.module.ts b/src/modules/dropdown/dropdown.module.ts index 85a2d02ae..6868212f4 100644 --- a/src/modules/dropdown/dropdown.module.ts +++ b/src/modules/dropdown/dropdown.module.ts @@ -1,11 +1,13 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { SkyWindowRefService } from '../window'; +import { SkyPopoverModule } from '../popover'; + import { SkyDropdownButtonComponent } from './dropdown-button.component'; import { SkyDropdownItemComponent } from './dropdown-item.component'; import { SkyDropdownMenuComponent } from './dropdown-menu.component'; import { SkyDropdownComponent } from './dropdown.component'; -import { SkyWindowRefService } from '../window'; @NgModule({ declarations: [ @@ -14,7 +16,10 @@ import { SkyWindowRefService } from '../window'; SkyDropdownItemComponent, SkyDropdownMenuComponent ], - imports: [CommonModule], + imports: [ + CommonModule, + SkyPopoverModule + ], exports: [ SkyDropdownButtonComponent, SkyDropdownComponent, diff --git a/src/modules/dropdown/fixtures/dropdown-fixtures.module.ts b/src/modules/dropdown/fixtures/dropdown-fixtures.module.ts index 0b2e48b65..d98586afd 100644 --- a/src/modules/dropdown/fixtures/dropdown-fixtures.module.ts +++ b/src/modules/dropdown/fixtures/dropdown-fixtures.module.ts @@ -1,17 +1,17 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { SkyDropdownModule } from '../'; +import { SkyDropdownModule } from '../index'; import { DropdownTestComponent } from './dropdown.component.fixture'; -import { DropdownParentTestComponent } from './dropdown-parent.component.fixture'; @NgModule({ declarations: [ - DropdownTestComponent, - DropdownParentTestComponent + DropdownTestComponent ], imports: [ CommonModule, + NoopAnimationsModule, SkyDropdownModule ], exports: [ diff --git a/src/modules/dropdown/fixtures/dropdown-parent.component.fixture.html b/src/modules/dropdown/fixtures/dropdown-parent.component.fixture.html deleted file mode 100644 index 7a0f552e7..000000000 --- a/src/modules/dropdown/fixtures/dropdown-parent.component.fixture.html +++ /dev/null @@ -1,64 +0,0 @@ -
    - - - -
    - - - - \ No newline at end of file diff --git a/src/modules/dropdown/fixtures/dropdown-parent.component.fixture.ts b/src/modules/dropdown/fixtures/dropdown-parent.component.fixture.ts deleted file mode 100644 index d1c34abfe..000000000 --- a/src/modules/dropdown/fixtures/dropdown-parent.component.fixture.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - selector: 'sky-test-cmp', - templateUrl: './dropdown-parent.component.fixture.html' -}) -export class DropdownParentTestComponent { - public trigger: String; - public buttonType: String; - public myTitle: string; - public buttonStyle: String; -} diff --git a/src/modules/dropdown/fixtures/dropdown.component.fixture.html b/src/modules/dropdown/fixtures/dropdown.component.fixture.html index 877b9ddf9..2b818c4d6 100644 --- a/src/modules/dropdown/fixtures/dropdown.component.fixture.html +++ b/src/modules/dropdown/fixtures/dropdown.component.fixture.html @@ -1,17 +1,23 @@ -
    +
    + [buttonStyle]="buttonStyle" + [messageStream]="dropdownController" + #dropdown> Show dropdown - - test + +
    + diff --git a/src/modules/dropdown/fixtures/dropdown.component.fixture.ts b/src/modules/dropdown/fixtures/dropdown.component.fixture.ts index b10a4a2d9..664fac1de 100644 --- a/src/modules/dropdown/fixtures/dropdown.component.fixture.ts +++ b/src/modules/dropdown/fixtures/dropdown.component.fixture.ts @@ -1,13 +1,62 @@ -import { Component } from '@angular/core'; +import { + ChangeDetectorRef, + Component, + ElementRef, + ViewChild +} from '@angular/core'; + +import { Subject } from 'rxjs/Subject'; + +import { + SkyDropdownMessageType, + SkyDropdownMessage +} from '../types'; + +import { + SkyDropdownComponent +} from '../dropdown.component'; @Component({ selector: 'sky-test-cmp', templateUrl: './dropdown.component.fixture.html' }) export class DropdownTestComponent { - public trigger: String; - public buttonType: String; - public myTitle: string; - public alignment: string = 'left'; + public alignment: string; public buttonStyle: string; + public buttonType: String; + public label: string; + public title: string; + public trigger: String; + public dropdownController = new Subject(); + + public items: any[] = [ + { name: 'Option 1', disabled: false }, + { name: 'Option 2', disabled: true }, + { name: 'Option 3', disabled: false }, + { name: 'Option 4', disabled: false } + ]; + + @ViewChild('dropdown') + public dropdown: SkyDropdownComponent; + + @ViewChild('outsideButton') + public outsideButton: ElementRef; + + constructor( + private changeDetector: ChangeDetectorRef + ) { } + + public sendMessage(type: SkyDropdownMessageType) { + this.dropdownController.next({ type }); + } + + public changeItems() { + this.items.pop(); + this.changeDetector.detectChanges(); + } + + public setItems(items: any[]) { + this.items = items; + this.changeDetector.detectChanges(); + } } diff --git a/src/modules/dropdown/index.ts b/src/modules/dropdown/index.ts index 1c40c45c1..862c984e8 100644 --- a/src/modules/dropdown/index.ts +++ b/src/modules/dropdown/index.ts @@ -1,4 +1,5 @@ -export { SkyDropdownComponent } from './dropdown.component'; -export { SkyDropdownItemComponent } from './dropdown-item.component'; -export { SkyDropdownMenuComponent } from './dropdown-menu.component'; -export { SkyDropdownModule } from './dropdown.module'; +export * from './dropdown.component'; +export * from './dropdown-item.component'; +export * from './dropdown-menu.component'; +export * from './dropdown.module'; +export * from './types'; diff --git a/src/modules/dropdown/types/dropdown-menu-change.ts b/src/modules/dropdown/types/dropdown-menu-change.ts new file mode 100644 index 000000000..60ab56cd9 --- /dev/null +++ b/src/modules/dropdown/types/dropdown-menu-change.ts @@ -0,0 +1,6 @@ +import { SkyDropdownItemComponent } from '../dropdown-item.component'; + +export interface SkyDropdownMenuChange { + activeIndex?: number; + selectedItem?: SkyDropdownItemComponent; +} diff --git a/src/modules/dropdown/types/dropdown-message-type.ts b/src/modules/dropdown/types/dropdown-message-type.ts new file mode 100644 index 000000000..2dd4c4dfe --- /dev/null +++ b/src/modules/dropdown/types/dropdown-message-type.ts @@ -0,0 +1,8 @@ +export enum SkyDropdownMessageType { + Open = 0, + Close = 1, + FocusTriggerButton = 2, + FocusNextItem = 3, + FocusPreviousItem = 4, + Reposition = 5 +} diff --git a/src/modules/dropdown/types/dropdown-message.ts b/src/modules/dropdown/types/dropdown-message.ts new file mode 100644 index 000000000..7ec1e591e --- /dev/null +++ b/src/modules/dropdown/types/dropdown-message.ts @@ -0,0 +1,5 @@ +import { SkyDropdownMessageType } from './dropdown-message-type'; + +export interface SkyDropdownMessage { + type?: SkyDropdownMessageType; +} diff --git a/src/modules/dropdown/types/dropdown-trigger-type.ts b/src/modules/dropdown/types/dropdown-trigger-type.ts new file mode 100644 index 000000000..c4d5db58a --- /dev/null +++ b/src/modules/dropdown/types/dropdown-trigger-type.ts @@ -0,0 +1 @@ +export type SkyDropdownTriggerType = 'click' | 'hover'; diff --git a/src/modules/dropdown/types/index.ts b/src/modules/dropdown/types/index.ts new file mode 100644 index 000000000..f3e61600e --- /dev/null +++ b/src/modules/dropdown/types/index.ts @@ -0,0 +1,4 @@ +export * from './dropdown-menu-change'; +export * from './dropdown-message'; +export * from './dropdown-message-type'; +export * from './dropdown-trigger-type'; diff --git a/src/modules/list-column-selector-action/list-column-selector-action.spec.ts b/src/modules/list-column-selector-action/list-column-selector-action.spec.ts index 637b72f7a..4805d5a8d 100644 --- a/src/modules/list-column-selector-action/list-column-selector-action.spec.ts +++ b/src/modules/list-column-selector-action/list-column-selector-action.spec.ts @@ -1,4 +1,5 @@ import { + flush, TestBed, ComponentFixture, inject, @@ -7,6 +8,10 @@ import { tick } from '@angular/core/testing'; +import { + NoopAnimationsModule +} from '@angular/platform-browser/animations'; + import { ListState, ListStateDispatcher @@ -67,7 +72,6 @@ describe('List column selector action', () => { TestBed.configureTestingModule({ declarations: [ ListColumnSelectorActionTestComponent - ], imports: [ SkyListColumnSelectorActionModule, @@ -76,79 +80,77 @@ describe('List column selector action', () => { SkyListSecondaryActionsModule, SkyGridModule, SkyListViewGridModule, - SkyColumnSelectorModule + SkyColumnSelectorModule, + NoopAnimationsModule ] }) .overrideComponent(SkyListComponent, { - set: { - providers: [ - { provide: ListState, useValue: state }, - { provide: ListStateDispatcher, useValue: dispatcher } - ] - } - }); + set: { + providers: [ + { provide: ListState, useValue: state }, + { provide: ListStateDispatcher, useValue: dispatcher } + ] + } + }); + })); + beforeEach(() => { fixture = TestBed.createComponent(ListColumnSelectorActionTestComponent); nativeElement = fixture.nativeElement as HTMLElement; component = fixture.componentInstance; - fixture.detectChanges(); + }); - // always skip the first update to ListState, when state is ready - // run detectChanges once more then begin tests - state.skip(1).take(1).subscribe(() => fixture.detectChanges()); - fixture.detectChanges(); + beforeEach(inject([SkyModalService], (_modalService: SkyModalService) => { + _modalService.dispose(); })); - beforeEach( - inject( - [ - SkyModalService - ], - ( - _modalService: SkyModalService - ) => { - _modalService.dispose(); - } - ) - ); + afterAll(() => { + fixture.destroy(); + }); - it('should show an action in the secondary actions dropdown', fakeAsync(() => { - tick(); + function getChooseColumnsButton() { + return nativeElement.querySelector('.sky-dropdown-menu button') as HTMLElement; + } + + function toggleSecondaryActionsDropdown() { fixture.detectChanges(); + flush(); tick(); - /* tslint:disable */ - let query = - '.sky-list-toolbar-container .sky-toolbar-item .sky-list-secondary-actions .sky-dropdown .sky-dropdown-menu sky-list-secondary-action'; - /* tslint:enable */ - expect(nativeElement.querySelector(query)).toHaveText('Choose columns'); - })); + fixture.detectChanges(); - function getChooseColumnsAction() { - /* tslint:disable */ - return nativeElement.querySelector('.sky-list-toolbar-container .sky-toolbar-item .sky-list-secondary-actions .sky-dropdown .sky-dropdown-menu sky-list-secondary-action button') as HTMLElement; - /* tslint:enable */ - } + const button = nativeElement.querySelector('.sky-dropdown-button') as HTMLButtonElement; + expect(button).toBeDefined(); - it('should open the appropriate modal on click and apply column changes on save', - fakeAsync(() => { - fixture.detectChanges(); + button.click(); + flush(); tick(); + fixture.detectChanges(); + } - getChooseColumnsAction().click(); + it('should show an action in the secondary actions dropdown', fakeAsync(() => { + toggleSecondaryActionsDropdown(); + + const chooseColumnsButton = getChooseColumnsButton(); + expect(chooseColumnsButton.textContent.trim()).toEqual('Choose columns'); + })); + + it('should open the appropriate modal on click and apply column changes on save', fakeAsync(() => { + toggleSecondaryActionsDropdown(); + + const chooseColumnsButton = getChooseColumnsButton(); + chooseColumnsButton.click(); tick(); - let checkboxLabelEl = - document - .querySelectorAll('.sky-modal .sky-list-view-checklist-item input') as - NodeListOf; + const checkboxLabelEl = document.querySelectorAll( + '.sky-modal .sky-list-view-checklist-item input' + ) as NodeListOf; expect(checkboxLabelEl.length).toBe(2); - checkboxLabelEl.item(0).click(); + checkboxLabelEl.item(0).click(); tick(); - let submitButtonEl = - document.querySelector('.sky-modal .sky-btn-primary') as HTMLButtonElement; + const submitButtonEl = document.querySelector('.sky-modal .sky-btn-primary') as HTMLButtonElement; submitButtonEl.click(); tick(); @@ -156,46 +158,56 @@ describe('List column selector action', () => { component.grid.gridState.take(1).subscribe((gridState) => { expect(gridState.displayedColumns.items.length).toBe(2); }); - tick(); + flush(); + tick(); })); it('should keep previous columns on cancel', fakeAsync(() => { - fixture.detectChanges(); - tick(); + toggleSecondaryActionsDropdown(); - getChooseColumnsAction().click(); + const chooseColumnsButton = getChooseColumnsButton(); + chooseColumnsButton.click(); tick(); - let checkboxLabelEl = - document.querySelector('.sky-modal .sky-list-view-checklist-item input') as HTMLElement; - checkboxLabelEl.click(); + const checkboxLabelEl = document.querySelectorAll( + '.sky-modal .sky-list-view-checklist-item input' + ) as NodeListOf; + checkboxLabelEl.item(0).click(); tick(); - let cancelButtonEl = - document.querySelector('.sky-modal [sky-cmp-id="cancel"]') as HTMLButtonElement; + const cancelButtonEl = document.querySelector('.sky-modal [sky-cmp-id="cancel"]') as HTMLButtonElement; cancelButtonEl.click(); - tick(); component.grid.gridState.take(1).subscribe((gridState) => { expect(gridState.displayedColumns.items.length).toBe(3); }); - tick(); + flush(); + tick(); })); it('should not appear if not in grid view', fakeAsync(() => { - tick(); - dispatcher.viewsSetActive('other'); - tick(); + fixture.detectChanges(); - /* tslint:disable */ - let query = - '.sky-list-toolbar-container .sky-toolbar-item .sky-list-secondary-actions .sky-dropdown .sky-dropdown-menu sky-list-secondary-action'; - /* tslint:enable */ - expect(nativeElement.querySelector(query)).toBeNull(); + // Skip the first update to ListState, when state is ready. + state.skip(1).take(1).subscribe(() => { + fixture.detectChanges(); + tick(); + dispatcher.viewsSetActive('other'); + tick(); + + /* tslint:disable */ + let query = + '.sky-list-toolbar-container .sky-toolbar-item .sky-list-secondary-actions .sky-dropdown .sky-dropdown-menu sky-list-secondary-action'; + /* tslint:enable */ + expect(nativeElement.querySelector(query)).toBeNull(); + }); + + flush(); + tick(); })); }); diff --git a/src/modules/list-toolbar/list-toolbar.component.spec.ts b/src/modules/list-toolbar/list-toolbar.component.spec.ts index ed9921f87..5cecace6c 100644 --- a/src/modules/list-toolbar/list-toolbar.component.spec.ts +++ b/src/modules/list-toolbar/list-toolbar.component.spec.ts @@ -2,6 +2,7 @@ import { TestBed, async, fakeAsync, + flush, tick, ComponentFixture } from '@angular/core/testing'; @@ -54,12 +55,14 @@ describe('List Toolbar Component', () => { { provide: ListStateDispatcher, useValue: dispatcher } ] }); + })); + beforeEach(() => { fixture = TestBed.createComponent(ListToolbarTestComponent); nativeElement = fixture.nativeElement as HTMLElement; element = fixture.debugElement as DebugElement; component = fixture.componentInstance; - })); + }); function initializeToolbar() { fixture.detectChanges(); @@ -243,22 +246,28 @@ describe('List Toolbar Component', () => { }); })); - it('should create ascending and descending items for each sort label', async(() => { + function verifyInnerText(elem: Element, needle: string) { + expect(elem.textContent.trim().indexOf(needle) > -1).toEqual(true); + } + + it('should create ascending and descending items for each sort label', fakeAsync(() => { initializeToolbar(); - fixture.whenStable().then(() => { - fixture.detectChanges(); - let sortItems = nativeElement.querySelectorAll('.sky-sort .sky-sort-item'); - expect(sortItems.length).toBe(8); - expect(sortItems.item(2)).toHaveText('Status (A - Z)'); - expect(sortItems.item(3)).toHaveText('Status (Z - A)'); - expect(sortItems.item(4)).toHaveText('Date (Most recent first)'); - expect(sortItems.item(5)).toHaveText('Date (Most recent last)'); - expect(sortItems.item(6)).toHaveText('Number (Highest first)'); - expect(sortItems.item(7)).toHaveText('Number (Lowest first)'); - expect(sortItems.item(0)).toHaveText('Custom'); - expect(sortItems.item(1)).toHaveText('Custom'); - }); + flush(); + tick(); + fixture.detectChanges(); + + const sortItems = nativeElement.querySelectorAll('.sky-sort .sky-sort-item'); + + expect(sortItems.length).toBe(8); + verifyInnerText(sortItems.item(0), 'Custom'); + verifyInnerText(sortItems.item(1), 'Custom'); + verifyInnerText(sortItems.item(2), 'Status (A - Z)'); + verifyInnerText(sortItems.item(3), 'Status (Z - A)'); + verifyInnerText(sortItems.item(4), 'Date (Most recent first)'); + verifyInnerText(sortItems.item(5), 'Date (Most recent last)'); + verifyInnerText(sortItems.item(6), 'Number (Highest first)'); + verifyInnerText(sortItems.item(7), 'Number (Lowest first)'); })); it('should handle sort item click', async(() => { diff --git a/src/modules/popover/fixtures/popover.component.fixture.html b/src/modules/popover/fixtures/popover.component.fixture.html index 172350a2e..6494efc5e 100644 --- a/src/modules/popover/fixtures/popover.component.fixture.html +++ b/src/modules/popover/fixtures/popover.component.fixture.html @@ -1,15 +1,33 @@ - +
    + - + + Some text. + - + - - Some text. - + + Some text. + + + + + + Some text. + + + + + + Some text. + +
    diff --git a/src/modules/popover/index.ts b/src/modules/popover/index.ts index 60c6b8cc3..f0aa6a15e 100644 --- a/src/modules/popover/index.ts +++ b/src/modules/popover/index.ts @@ -1,6 +1,12 @@ +export { + SkyPopoverAdapterElements, + SkyPopoverAlignment, + SkyPopoverPlacement, + SkyPopoverPosition, + SkyPopoverTrigger +} from './types'; + export * from './popover-adapter.service'; -export * from './popover-placement'; -export * from './popover-trigger'; export * from './popover.directive'; export * from './popover.component'; export * from './popover.module'; diff --git a/src/modules/popover/popover-adapter.service.spec.ts b/src/modules/popover/popover-adapter.service.spec.ts index 1d87a52e3..d7b2ae6d7 100644 --- a/src/modules/popover/popover-adapter.service.spec.ts +++ b/src/modules/popover/popover-adapter.service.spec.ts @@ -16,15 +16,16 @@ import { SkyPopoverAdapterService } from './popover-adapter.service'; +import { + SkyPopoverPosition +} from './types'; + describe('SkyPopoverAdapterService', () => { let mockRenderer: any; class MockWindowService { public getWindow(): any { return { - setTimeout(callback: Function) { - callback(); - }, document: { body: { clientWidth: 800, @@ -40,21 +41,43 @@ describe('SkyPopoverAdapterService', () => { function createElementRefDefinition( top = 0, left = 0, - right = 0, width = 0, height = 0 ) { - return { + const def: any = { getBoundingClientRect: function () { return { top: top, left: left, - right: right, width: width, height: height }; - } + }, + offsetTop: top, + offsetLeft: left, + className: '', + style: {} + }; + + def.setOffsets = (offsetTop: number, offsetLeft: number) => { + def.offsetTop = offsetTop; + def.offsetLeft = offsetLeft; }; + + return def; + } + + function verifyPosition( + position: SkyPopoverPosition, + top: number, + left: number, + arrowTop: number, + arrowLeft: number + ) { + expect(position.top).toEqual(top); + expect(position.left).toEqual(left); + expect(position.arrowTop).toEqual(arrowTop); + expect(position.arrowLeft).toEqual(arrowLeft); } beforeEach(() => { @@ -76,50 +99,113 @@ describe('SkyPopoverAdapterService', () => { it('should set a popover\'s top and left coordinates', inject([SkyPopoverAdapterService], (adapterService: SkyPopoverAdapterService) => { - const spy = spyOn(adapterService['renderer'], 'setStyle'); - const popover = new ElementRef(createElementRefDefinition(0, 0, 0, 180, 70)); - const popoverArrow = new ElementRef(createElementRefDefinition(0, 0, 0, 20, 10)); + const popover = new ElementRef(createElementRefDefinition(0, 0, 180, 70)); + const popoverArrow = new ElementRef(createElementRefDefinition(0, 0, 20, 10)); + + const position = adapterService.getPopoverPosition({ + popover, + popoverArrow, + caller: new ElementRef(createElementRefDefinition(200, 200, 80, 34)) + }, 'above', 'center'); + + verifyPosition(position, 130, 150, undefined, 90); + }) + ); - adapterService.setPopoverPosition({ + it('should set a popover\'s arrow top and left coordinates', + inject([SkyPopoverAdapterService, SkyWindowRefService], ( + adapterService: SkyPopoverAdapterService, + windowService: SkyWindowRefService + ) => { + spyOn(windowService, 'getWindow').and.returnValue({ + setTimeout(callback: Function) { + callback(); + }, + document: { + body: { // document dimensions + clientWidth: 800, + clientHeight: 800 + } + }, + innerHeight: 300, // viewport height + pageXOffset: 0, + pageYOffset: 100 // scroll top + }); + + const caller = new ElementRef(createElementRefDefinition(750, 0, 100, 34)); + const popover = new ElementRef(createElementRefDefinition(0, 0, 200, 300)); + const popoverArrow = new ElementRef(createElementRefDefinition()); + + const position = adapterService.getPopoverPosition({ popover, popoverArrow, - caller: new ElementRef(createElementRefDefinition(200, 200, 0, 80, 34)) - }, 'right'); + caller + }, 'right', undefined); - expect(spy).toHaveBeenCalledWith(popover.nativeElement, 'top', `182px`); - expect(spy).toHaveBeenCalledWith(popover.nativeElement, 'left', `0px`); - expect(spy).toHaveBeenCalledWith(popoverArrow.nativeElement, 'top', `35px`); - expect(spy).toHaveBeenCalledWith(popoverArrow.nativeElement, 'left', undefined); + expect(position.arrowTop).toEqual(367); + expect(position.arrowLeft).toEqual(undefined); }) ); - it('should default the placement to "above"', + it('should handle invalid placement and alignment values', inject([SkyPopoverAdapterService], (adapterService: SkyPopoverAdapterService) => { - const spy = spyOn(adapterService as any, 'getCoordinates').and.callThrough(); + const popover = new ElementRef(createElementRefDefinition(0, 0, 180, 70)); + const popoverArrow = new ElementRef(createElementRefDefinition(0, 0, 20, 10)); - // Setting the caller's top value to 200 will make sure the popover has - // enough room to appear above. - adapterService.setPopoverPosition({ - popover: new ElementRef(createElementRefDefinition(0, 0, 0, 276, 100)), - popoverArrow: new ElementRef(createElementRefDefinition()), - caller: new ElementRef(createElementRefDefinition(200, 0, 100, 100, 34)) - }, undefined); + const position = (adapterService as any).getPopoverPosition({ + popover, + popoverArrow, + caller: new ElementRef(createElementRefDefinition(200, 200, 80, 34)) + }, 'foo', 'bar'); - expect(spy.calls.mostRecent().args[1]).toEqual('above'); + verifyPosition(position, undefined, undefined, undefined, undefined); + }) + ); + + it('should allow for left and right alignment', + inject([SkyPopoverAdapterService], (adapterService: SkyPopoverAdapterService) => { + const popover = new ElementRef(createElementRefDefinition(0, 0, 180, 70)); + const popoverArrow = new ElementRef(createElementRefDefinition(0, 0, 20, 10)); + + let position = adapterService.getPopoverPosition({ + popover, + popoverArrow, + caller: new ElementRef(createElementRefDefinition(200, 200, 80, 34)) + }, 'below', 'left'); + + verifyPosition(position, 234, 200, undefined, 40); + + position = adapterService.getPopoverPosition({ + popover, + popoverArrow, + caller: new ElementRef(createElementRefDefinition(200, 200, 80, 34)) + }, 'below', 'right'); + + verifyPosition(position, 234, 100, undefined, 140); }) ); it('should attempt to find the optimal placement if outside viewport', inject([SkyPopoverAdapterService], (adapterService: SkyPopoverAdapterService) => { - const spy = spyOn(adapterService as any, 'getCoordinates').and.callThrough(); + const position = adapterService.getPopoverPosition({ + popover: new ElementRef(createElementRefDefinition(0, 0, 276, 100)), + popoverArrow: new ElementRef(createElementRefDefinition()), + caller: new ElementRef(createElementRefDefinition(0, 0, 100, 34)) + }, 'above', undefined); + + expect(position.placement).toEqual('below'); + }) + ); - adapterService.setPopoverPosition({ - popover: new ElementRef(createElementRefDefinition(0, 0, 0, 276, 100)), + it('should return placement of fullscreen if popover dimensions greater than viewport', + inject([SkyPopoverAdapterService], (adapterService: SkyPopoverAdapterService) => { + const position = adapterService.getPopoverPosition({ + popover: new ElementRef(createElementRefDefinition(0, 0, 1500, 1500)), popoverArrow: new ElementRef(createElementRefDefinition()), - caller: new ElementRef(createElementRefDefinition(0, 0, 100, 100, 34)) - }, 'above'); + caller: new ElementRef(createElementRefDefinition(0, 0, 100, 34)) + }, 'above', undefined); - expect(spy.calls.mostRecent().args[1]).toEqual('below'); + expect(position.placement).toEqual('fullscreen'); }) ); @@ -127,7 +213,7 @@ describe('SkyPopoverAdapterService', () => { inject( [SkyPopoverAdapterService, SkyWindowRefService], (adapterService: SkyPopoverAdapterService, windowService: SkyWindowRefService) => { - const spy = spyOn(adapterService as any, 'getCoordinates').and.callThrough(); + const spy = spyOn(adapterService as any, 'getPopoverCoordinates').and.callThrough(); // For this test, the window's dimensions have been set to be smaller than the popover. // All cardinal directions should be checked (and fail), @@ -138,31 +224,29 @@ describe('SkyPopoverAdapterService', () => { }, document: { body: { - clientWidth: 100, - clientHeight: 50 + clientWidth: 300, + clientHeight: 300 } }, + innerHeight: 300, pageXOffset: 0, pageYOffset: 0 }); const elements = { - popover: new ElementRef(createElementRefDefinition(0, 0, 0, 276, 100)), + popover: new ElementRef(createElementRefDefinition(0, 0, 276, 276)), popoverArrow: new ElementRef(createElementRefDefinition()), - caller: new ElementRef(createElementRefDefinition(0, 0, 100, 100, 34)) + caller: new ElementRef(createElementRefDefinition(0, 0, 50, 50)) }; - adapterService.setPopoverPosition(elements, 'below'); - expect(spy.calls.mostRecent().args[1]).toEqual('above'); - - adapterService.setPopoverPosition(elements, 'right'); - expect(spy.calls.mostRecent().args[1]).toEqual('left'); - - adapterService.setPopoverPosition(elements, 'left'); - expect(spy.calls.mostRecent().args[1]).toEqual('right'); + const position = adapterService.getPopoverPosition(elements, 'above', undefined); - adapterService.setPopoverPosition(elements, 'above'); - expect(spy.calls.mostRecent().args[1]).toEqual('below'); + expect(spy.calls.count()).toEqual(4); + expect(spy.calls.argsFor(0)[1]).toEqual('above'); + expect(spy.calls.argsFor(1)[1]).toEqual('below'); + expect(spy.calls.argsFor(2)[1]).toEqual('left'); + expect(spy.calls.argsFor(3)[1]).toEqual('right'); + expect(position.placement).toEqual('fullscreen'); } ) ); @@ -172,7 +256,7 @@ describe('SkyPopoverAdapterService', () => { const spy = spyOn(adapterService['renderer'], 'addClass'); const elem = new ElementRef({ nativeElement: {} }); adapterService.hidePopover(elem); - expect(spy).toHaveBeenCalledWith(elem.nativeElement, 'hidden'); + expect(spy).toHaveBeenCalledWith(elem.nativeElement, 'sky-popover-hidden'); }) ); @@ -181,7 +265,55 @@ describe('SkyPopoverAdapterService', () => { const spy = spyOn(adapterService['renderer'], 'removeClass'); const elem = new ElementRef({ nativeElement: {} }); adapterService.showPopover(elem); - expect(spy).toHaveBeenCalledWith(elem.nativeElement, 'hidden'); + expect(spy).toHaveBeenCalledWith(elem.nativeElement, 'sky-popover-hidden'); + }) + ); + + it('should handle out-of-bounds coordinates for arrows', + inject([SkyPopoverAdapterService], (adapterService: SkyPopoverAdapterService) => { + const popover = new ElementRef(createElementRefDefinition(0, 0, 180, 70)); + const popoverArrow = new ElementRef(createElementRefDefinition(0, 0, 20, 10)); + + let position = adapterService.getPopoverPosition({ + popover, + popoverArrow, + // Make the button larger than the screen: + caller: new ElementRef(createElementRefDefinition(200, 200, 4000, 34)) + }, 'above', 'left'); + + // The arrow is out of bounds, so resort to the CSS defaults: + expect(position.arrowTop).toEqual(undefined); + expect(position.arrowLeft).toEqual(undefined); + + position = adapterService.getPopoverPosition({ + popover, + popoverArrow, + caller: new ElementRef(createElementRefDefinition(200, 200, 4000, 34)) + }, 'above', 'right'); + + // The arrow is out of bounds, so resort to the CSS defaults: + expect(position.arrowTop).toEqual(undefined); + expect(position.arrowLeft).toEqual(undefined); + }) + ); + + it('should stick the popover to the button\'s horizontal dimensions', + inject([SkyPopoverAdapterService], (adapterService: SkyPopoverAdapterService) => { + const callerDef = createElementRefDefinition(200, -5, 100, 34); + const popover = new ElementRef(createElementRefDefinition(0, 0, 200, 100)); + const popoverArrow = new ElementRef(createElementRefDefinition()); + + callerDef.setOffsets(0, 10); + + const position = adapterService.getPopoverPosition({ + popover, + popoverArrow, + caller: new ElementRef(callerDef) + }, 'above', 'right'); + + expect(position.top).toEqual(-100); + // The popover left coordinate should never be less than the button's offsetLeft. + expect(position.left).toEqual(10); }) ); }); diff --git a/src/modules/popover/popover-adapter.service.ts b/src/modules/popover/popover-adapter.service.ts index 47e986040..0c9129765 100644 --- a/src/modules/popover/popover-adapter.service.ts +++ b/src/modules/popover/popover-adapter.service.ts @@ -4,218 +4,300 @@ import { Renderer2 } from '@angular/core'; -import { SkyWindowRefService } from '../window'; -import { SkyPopoverPlacement } from './index'; - -export interface SkyPopoverCoordinates { - top: number; - left: number; - arrowTop: number; - arrowLeft: number; - isOutsideViewport: boolean; -} +import { + SkyWindowRefService +} from '../window'; -export interface SkyPopoverAdapterElements { - popover: ElementRef; - popoverArrow: ElementRef; - caller: ElementRef; -} +import { + SkyPopoverAdapterArrowCoordinates, + SkyPopoverAdapterCoordinates, + SkyPopoverAdapterElements, + SkyPopoverAdapterParentDimensions, + SkyPopoverAlignment, + SkyPopoverPlacement, + SkyPopoverPosition +} from './types'; @Injectable() export class SkyPopoverAdapterService { constructor( private renderer: Renderer2, - private windowRef: SkyWindowRefService) { } + private windowRef: SkyWindowRefService + ) { } - public setPopoverPosition( + public getPopoverPosition( elements: SkyPopoverAdapterElements, - placement: SkyPopoverPlacement = 'above' - ) { - this.clearElementCoordinates(elements.popover); - this.clearElementCoordinates(elements.popoverArrow); + placement: SkyPopoverPlacement, + alignment: SkyPopoverAlignment + ): SkyPopoverPosition { + const max = 4; - const coords = this.getVisibleCoordinates(elements, placement); + let counter = 0; + let coords: SkyPopoverAdapterCoordinates = { + top: undefined, + left: undefined, + isOutsideViewport: true + }; + + if (!placement || placement === 'fullscreen' || this.popoverLargerThanParent(elements)) { + placement = 'fullscreen'; + } else { + do { + coords = this.getPopoverCoordinates(elements, placement, alignment); + if (coords.isOutsideViewport) { + placement = (counter % 2 === 0) ? + this.getInversePlacement(placement) : + this.getNextPlacement(placement); + } + } while (coords.isOutsideViewport && ++counter < max); + + if (counter === max) { + placement = 'fullscreen'; + } + } - this.setElementCoordinates(elements.popover, coords.top, coords.left); - this.setElementCoordinates(elements.popoverArrow, coords.arrowTop, coords.arrowLeft); + const arrowCoords = this.getArrowCoordinates(elements, coords, placement); + + return { + top: coords.top, + left: coords.left, + arrowTop: arrowCoords.top, + arrowLeft: arrowCoords.left, + placement, + alignment + }; } public hidePopover(elem: ElementRef): void { - this.renderer.addClass(elem.nativeElement, 'hidden'); + this.renderer.addClass(elem.nativeElement, 'sky-popover-hidden'); } public showPopover(elem: ElementRef): void { - this.renderer.removeClass(elem.nativeElement, 'hidden'); + this.renderer.removeClass(elem.nativeElement, 'sky-popover-hidden'); } - private getVisibleCoordinates( - elements: SkyPopoverAdapterElements, - placement: SkyPopoverPlacement - ): SkyPopoverCoordinates { - const max = 4; - - let counter = 0; - let coords: SkyPopoverCoordinates; - - do { - coords = this.getCoordinates(elements, placement); - if (coords.isOutsideViewport) { - placement = this.getNextPlacement(placement); - } - } while (coords.isOutsideViewport && ++counter < max); - - if (counter === max) { - placement = this.getInversePlacement(placement); - coords = this.getCoordinates(elements, placement); - } - - return coords; + public clearConcreteDimensions(elem: ElementRef): void { + this.renderer.removeStyle(elem.nativeElement, 'width'); + this.renderer.removeStyle(elem.nativeElement, 'height'); + this.renderer.removeClass(elem.nativeElement, 'sky-popover-placement-fullscreen'); } - private getCoordinates( + private getPopoverCoordinates( elements: SkyPopoverAdapterElements, - placement: SkyPopoverPlacement - ): SkyPopoverCoordinates { - const callerRect = elements.caller.nativeElement.getBoundingClientRect(); - const popoverRect = elements.popover.nativeElement.getBoundingClientRect(); - const window = this.windowRef.getWindow(); - - const documentWidth = window.document.body.clientWidth; - const documentHeight = window.document.body.clientHeight; - - const leftCenter = callerRect.left - (popoverRect.width / 2) + (callerRect.width / 2); - const topCenter = callerRect.top - (popoverRect.height / 2) + (callerRect.height / 2); - - let top; - let left; - let arrowTop; - let arrowLeft; - + placement: SkyPopoverPlacement, + alignment: SkyPopoverAlignment + ): SkyPopoverAdapterCoordinates { + const callerElement = elements.caller.nativeElement; + const popoverElement = elements.popover.nativeElement; + + const callerRect = callerElement.getBoundingClientRect(); + const popoverRect = popoverElement.getBoundingClientRect(); + + const callerOffsetLeft = callerElement.offsetLeft; + const callerOffsetTop = callerElement.offsetTop; + + const windowObj = this.windowRef.getWindow(); + const parent = this.getParentDimensions(); + const viewportOffsetBottom = windowObj.innerHeight + windowObj.pageYOffset; + + let top: number; + let left: number; + let bleedLeft = 0; + let bleedRight = 0; + let bleedTop = 0; + let bleedBottom = 0; let isOutsideViewport = false; - // tslint:disable:switch-default - // All possible types are represented; default unnecessary. + /* tslint:disable:switch-default */ switch (placement) { case 'above': - left = leftCenter; - top = callerRect.top - popoverRect.height; - arrowLeft = (popoverRect.width / 2); - break; + top = callerOffsetTop - popoverRect.height; + bleedTop = callerRect.top - popoverRect.height; + break; case 'below': - left = leftCenter; - top = callerRect.top + callerRect.height; - arrowLeft = (popoverRect.width / 2); - break; + top = callerOffsetTop + callerRect.height; + bleedBottom = viewportOffsetBottom - (callerRect.top + callerRect.height + popoverRect.height + windowObj.pageYOffset); + break; case 'right': - top = topCenter; - left = callerRect.right; - arrowTop = (popoverRect.height / 2); - break; + left = callerOffsetLeft + callerRect.width; + bleedRight = parent.width - (callerRect.left + callerRect.width + popoverRect.width); + break; case 'left': - top = topCenter; - left = callerRect.left - popoverRect.width; - arrowTop = (popoverRect.height / 2); - break; + left = callerOffsetLeft - popoverRect.width; + bleedLeft = callerRect.left - popoverRect.width; + break; + } + /* tslint:enable */ + + if (placement === 'right' || placement === 'left') { + top = callerOffsetTop - (popoverRect.height / 2) + (callerRect.height / 2); + bleedTop = windowObj.pageYOffset + (callerRect.top - (popoverRect.height / 2) + (callerRect.height / 2)); + bleedBottom = parent.height - ( + callerRect.top + windowObj.pageYOffset + ((callerRect.height / 2) + (popoverRect.height / 2)) + ); } - // tslint:enable:switch-default - left += window.pageXOffset; - top += window.pageYOffset; + // Make adjustments based on horizontal alignment. + if (placement === 'above' || placement === 'below') { + /* tslint:disable:switch-default */ + switch (alignment) { + case 'center': + left = callerOffsetLeft - (popoverRect.width / 2) + (callerRect.width / 2); + bleedLeft = callerRect.left - (popoverRect.width / 2) + (callerRect.width / 2); + bleedRight = parent.width - (callerRect.left + (popoverRect.width / 2) + (callerRect.width / 2)); + break; - // Clipped on the right? - if (callerRect.right + (popoverRect.width / 2) > documentWidth) { - if (placement === 'right') { + case 'left': + left = callerOffsetLeft; + bleedLeft = callerRect.left; + bleedRight = parent.width - (bleedLeft + popoverRect.width); + break; + + case 'right': + left = callerOffsetLeft - popoverRect.width + callerRect.width; + bleedLeft = callerRect.left - popoverRect.width + callerRect.width; + bleedRight = parent.width - (callerRect.left + callerRect.width); + break; + } + /* tslint:enable */ + } + + // Clipped on left? + if (bleedLeft < 0) { + if (placement === 'left') { isOutsideViewport = true; } - if (placement === 'above' || placement === 'below') { - arrowLeft = popoverRect.width - (documentWidth - callerRect.right + (callerRect.width / 2)); - left = documentWidth - popoverRect.width; + left -= bleedLeft; + + // Prevent popover's left boundary from leaving the bounds of the caller. + if (callerRect.left < 0) { + left = callerOffsetLeft; } } - // Clipped on the left? - if (left <= 0) { - if (placement === 'left') { + // Clipped on right? + if (bleedRight < 0) { + if (placement === 'right') { isOutsideViewport = true; } - if (placement === 'above' || placement === 'below') { - arrowLeft = callerRect.left + (callerRect.width / 2); - left = window.pageXOffset; + left += bleedRight; + + // Prevent popover's right boundary from leaving the bounds of the caller. + if (left + popoverRect.width < callerOffsetLeft + callerRect.width) { + left = callerOffsetLeft - popoverRect.width + callerRect.width; } } - // Clipped above? - if (top <= 0) { + // Clipped on top? + if (bleedTop < 0) { if (placement === 'above') { isOutsideViewport = true; } - if (placement === 'left' || placement === 'right') { - arrowTop = callerRect.top + (callerRect.height / 2); - top = window.pageYOffset; - } + top -= bleedTop; } - // Clipped below? - if (top + popoverRect.height >= documentHeight) { + // Clipped on bottom? + if (bleedBottom < 0) { if (placement === 'below') { isOutsideViewport = true; } - if (placement === 'left' || placement === 'right') { - arrowTop = documentHeight - callerRect.top - window.pageYOffset + callerRect.height; - top = documentHeight - popoverRect.height; - } + top += bleedBottom; } return { top, left, - arrowTop, - arrowLeft, isOutsideViewport - } as SkyPopoverCoordinates; + }; } - private clearElementCoordinates(elem: ElementRef): void { - this.renderer.removeStyle(elem.nativeElement, 'top'); - this.renderer.removeStyle(elem.nativeElement, 'left'); - } + private getArrowCoordinates( + elements: SkyPopoverAdapterElements, + popoverCoords: SkyPopoverAdapterCoordinates, + placement: SkyPopoverPlacement + ): SkyPopoverAdapterArrowCoordinates { + const callerRect = elements.caller.nativeElement.getBoundingClientRect(); + const popoverRect = elements.popover.nativeElement.getBoundingClientRect(); - private setElementCoordinates(elem: ElementRef, top: number, left: number) { - let topStyle; - let leftStyle; + const callerOffsetTop = elements.caller.nativeElement.offsetTop; + const callerOffsetLeft = elements.caller.nativeElement.offsetLeft; - if (top !== undefined) { - topStyle = `${top}px`; + let left: number; + let top: number; + + switch (placement) { + default: + case 'above': + case 'below': + left = callerOffsetLeft - popoverCoords.left + (callerRect.width / 2); + break; + + case 'right': + case 'left': + top = callerOffsetTop - popoverCoords.top + (callerRect.height / 2); + break; } - if (left !== undefined) { - leftStyle = `${left}px`; + // The arrow has exceded the bounds of the popover; + // default to the CSS className position. + if (left < 0 || left > popoverRect.width || isNaN(left)) { + left = undefined; } - this.renderer.setStyle(elem.nativeElement, 'top', topStyle); - this.renderer.setStyle(elem.nativeElement, 'left', leftStyle); + return { top, left }; } - private getNextPlacement(placement: SkyPopoverPlacement): SkyPopoverPlacement { - const placements: SkyPopoverPlacement[] = ['above', 'below', 'right', 'left']; + private popoverLargerThanParent(elements: SkyPopoverAdapterElements) { + const parentDimensions = this.getParentDimensions(); - let index = placements.indexOf(placement) + 1; - if (index === placements.length) { - index = 0; - } + // Set concrete dimensions after we've determined the document height. + this.setConcreteDimensions(elements.popover); + + const popoverRect = elements.popover.nativeElement.getBoundingClientRect(); + + return ( + popoverRect.height > parentDimensions.height || + popoverRect.width > parentDimensions.width + ); + } + private getNextPlacement(placement: SkyPopoverPlacement): SkyPopoverPlacement { + const placements: SkyPopoverPlacement[] = ['above', 'right', 'below', 'left']; + const index = placements.indexOf(placement) + 1; return placements[index]; } private getInversePlacement(placement: SkyPopoverPlacement): SkyPopoverPlacement { - const pairings = { above: 'below', below: 'above', right: 'left', left: 'right' }; + const pairings: any = { above: 'below', below: 'above', right: 'left', left: 'right' }; return pairings[placement] as SkyPopoverPlacement; } + + private setConcreteDimensions(elementRef: ElementRef) { + const element = elementRef.nativeElement; + const rect = element.getBoundingClientRect(); + element.style.width = `${rect.width}px`; + element.style.height = `${rect.height}px`; + } + + private getParentDimensions(): SkyPopoverAdapterParentDimensions { + const windowObj = this.windowRef.getWindow(); + + const documentHeight = windowObj.document.body.clientHeight; + const viewportHeight = windowObj.innerHeight; + + const width = windowObj.document.body.clientWidth; + const height = (viewportHeight > documentHeight) ? viewportHeight : documentHeight; + + return { + width, + height + }; + } } diff --git a/src/modules/popover/popover.component.html b/src/modules/popover/popover.component.html index ff263cc1a..619a14691 100644 --- a/src/modules/popover/popover.component.html +++ b/src/modules/popover/popover.component.html @@ -1,8 +1,11 @@
    @@ -13,6 +16,11 @@

    -
    +

    diff --git a/src/modules/popover/popover.component.scss b/src/modules/popover/popover.component.scss index b15802537..5bb94d415 100644 --- a/src/modules/popover/popover.component.scss +++ b/src/modules/popover/popover.component.scss @@ -1,160 +1,162 @@ -@import '../../scss/_variables'; @import '../../scss/_mixins'; -$arrow-size: 10px; +$popover-arrow-size: 20px; +$popover-border-size: 10px; $popover-max-width: 276px; -@mixin popover-border ($placement) { - margin-#{$placement}: $arrow-size; - - &:after { - content: ''; - display: block; - position: absolute; - #{$placement}: 0; - @include sky-shadow; - } -} - -:host { - width: 0; - height: 0; -} - .sky-popover-container { - visibility: hidden; - opacity: 0; position: absolute; - z-index: 9999; - - @media (max-width: $sky-screen-xs-max) { - left: 0 !important; - right: 0; - width: 100% !important + z-index: $sky-dropdown-z-index; + min-width: $popover-max-width; + max-width: $popover-max-width; + + &.sky-popover-hidden { + visibility: hidden; + opacity: 0; + top: -9999px !important; + left: -9999px !important; } - @media (min-width: $sky-screen-sm-min) { - max-width: $popover-max-width; + &:focus { + outline: none; } } .sky-popover { - position: relative; - background-color: $sky-color-white; @include sky-shadow; + background-color: $sky-color-white; border-radius: $sky-border-radius; +} + +.sky-popover-header { + padding: $sky-padding $sky-padding 0 $sky-padding; - @media (max-width: $sky-screen-xs-max) { - margin: 10px !important; - border: none !important; - border-top: $arrow-size solid $sky-highlight-color-info !important; + & + .sky-popover-body { + padding-top: 2px; } } +.sky-popover-title { + @include sky-emphasized; + margin: 0; +} + +.sky-popover-body { + padding: $sky-padding; +} + .sky-popover-arrow { width: 0; height: 0; position: absolute; - border: $arrow-size solid transparent; + border: $popover-border-size solid transparent; +} + +.sky-popover-placement-fullscreen { + padding: $sky-padding; + + > .sky-popover { + position: fixed; + top: $sky-padding; + left: $sky-padding; + right: $sky-padding; + bottom: $sky-padding; + overflow: hidden; + border-radius: $sky-border-radius; + display: flex; + flex-direction: column; + @include sky-border(dark, top, bottom, left, right); + @include sky-shadow(); + + .sky-popover-header { + padding: $sky-padding; + border-bottom: 1px solid $sky-border-color-neutral-light; + } - @media (max-width: $sky-screen-xs-max) { - display: none !important; + .sky-popover-body { + overflow: auto; + position: relative; + } } } .sky-popover-placement-above { - .sky-popover { - @include popover-border('bottom'); - border-bottom: $arrow-size solid $sky-highlight-color-info; + padding-bottom: $popover-border-size; - &:after { - left: 0; - right: 0; - } + .sky-popover { + border-bottom: $popover-border-size solid $sky-highlight-color-info; } .sky-popover-arrow { border-bottom: 0; border-top-color: $sky-highlight-color-info; - bottom: $arrow-size * -2; + bottom: 0; left: 50%; - margin-left: -$arrow-size; + margin-left: -$popover-arrow-size / 2; } } .sky-popover-placement-below { - .sky-popover { - @include popover-border('top'); - border-top: $arrow-size solid $sky-highlight-color-info; + padding-top: $popover-border-size; - &:after { - left: 0; - right: 0; - } + .sky-popover { + border-top: $popover-border-size solid $sky-highlight-color-info; } .sky-popover-arrow { border-top: 0; border-bottom-color: $sky-highlight-color-info; - top: $arrow-size * -2; + top: 0; left: 50%; - margin-left: -$arrow-size; + margin-left: -$popover-arrow-size / 2; } } .sky-popover-placement-right { - .sky-popover { - @include popover-border('left'); - border-left: $arrow-size solid $sky-highlight-color-info; + padding-left: $popover-border-size; - &:after { - top: 0; - bottom: 0; - } + .sky-popover { + border-left: $popover-border-size solid $sky-highlight-color-info; } .sky-popover-arrow { border-left: 0; border-right-color: $sky-highlight-color-info; - left: $arrow-size * -2; + left: 0; top: 50%; - margin-top: -$arrow-size; + margin-top: -$popover-arrow-size / 2; } } .sky-popover-placement-left { - .sky-popover { - @include popover-border('right'); - border-right: $arrow-size solid $sky-highlight-color-info; + padding-right: $popover-border-size; - &:after { - top: 0; - bottom: 0; - } + .sky-popover { + border-right: $popover-border-size solid $sky-highlight-color-info; } .sky-popover-arrow { border-right: 0; border-left-color: $sky-highlight-color-info; - right: $arrow-size * -2; + right: 0; top: 50%; - margin-top: -$arrow-size; + margin-top: -$popover-arrow-size / 2; } } -.sky-popover-header { - padding: $sky-padding $sky-padding 0 $sky-padding; - - & + .sky-popover-body { - padding-top: 2px; +.sky-popover-placement-above, +.sky-popover-placement-below { + &.sky-popover-alignment-left { + .sky-popover-arrow { + left: $popover-arrow-size * 2; + right: auto; + } } -} -.sky-popover-title { - margin: 0; - @include sky-emphasized; -} - -.sky-popover-body { - padding: $sky-padding; + &.sky-popover-alignment-right { + .sky-popover-arrow { + left: auto; + right: $popover-arrow-size * 2; + } + } } diff --git a/src/modules/popover/popover.component.spec.ts b/src/modules/popover/popover.component.spec.ts index 452c9d8c3..2ea58e106 100644 --- a/src/modules/popover/popover.component.spec.ts +++ b/src/modules/popover/popover.component.spec.ts @@ -1,21 +1,18 @@ -import { - By -} from '@angular/platform-browser'; - import { ElementRef } from '@angular/core'; import { ComponentFixture, - TestBed + fakeAsync, + TestBed, + tick } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { SkyWindowRefService } from '../window'; import { TestUtility } from '../testing/testutility'; import { expect } from '../testing'; @@ -25,35 +22,24 @@ import { SkyPopoverAdapterService } from './index'; -class MockWindowService { - public getWindow(): any { - return { - setTimeout(callback: Function) { - callback(); - } - }; - } +class MockPopoverAdapterService { + public getPopoverPosition() {} + public hidePopover() {} + public showPopover() {} } describe('SkyPopoverComponent', () => { let fixture: ComponentFixture; let component: SkyPopoverComponent; + let mockAdapterService: MockPopoverAdapterService; beforeEach(() => { - let mockWindowService = new MockWindowService(); - let mockAdapterService = { - setPopoverPosition() {}, - hidePopover() {}, - showPopover() {} - }; + mockAdapterService = new MockPopoverAdapterService(); TestBed.configureTestingModule({ imports: [ NoopAnimationsModule, SkyPopoverModule - ], - providers: [ - { provide: SkyWindowRefService, useValue: mockWindowService } ] }) .compileComponents(); @@ -71,35 +57,60 @@ describe('SkyPopoverComponent', () => { fixture.detectChanges(); }); - it('should call the adapter service to position the popover', () => { + it('should call the adapter service to position the popover', fakeAsync(() => { const caller = new ElementRef({}); - const spy = spyOn(component['adapterService'], 'setPopoverPosition'); + const spy = spyOn(mockAdapterService, 'getPopoverPosition').and.returnValue({ + top: 0, + left: 0, + arrowLeft: 0, + arrowRight: 0, + placement: 'above', + alignment: undefined + }); component.positionNextTo(caller, 'above'); + tick(); expect(spy).toHaveBeenCalled(); - }); + })); - it('should call the adapter service with a default position', () => { + it('should call the adapter service with a default position', fakeAsync(() => { const caller = new ElementRef({}); - const spy = spyOn(component['adapterService'], 'setPopoverPosition'); - component.positionNextTo(caller, undefined); - expect(spy).toHaveBeenCalled(); - expect(component.placement).toEqual('above'); - }); + const spy = spyOn(mockAdapterService, 'getPopoverPosition').and.returnValue({ + top: 0, + left: 0, + arrowLeft: 0, + arrowRight: 0, + placement: undefined, + alignment: undefined + }); + component.alignment = undefined; + component.placement = undefined; + component.positionNextTo(caller, undefined, undefined); + tick(); + expect(spy.calls.argsFor(0)[1]).toEqual('above'); + expect(spy.calls.argsFor(0)[2]).toEqual('center'); + })); it('should not call the adapter service if a caller is not defined', () => { - const spy = spyOn(component['adapterService'], 'setPopoverPosition'); + const spy = spyOn(mockAdapterService, 'getPopoverPosition'); component.positionNextTo(undefined, 'above'); expect(spy).not.toHaveBeenCalled(); }); it('should close a popover', () => { + component.isOpen = true; + component.animationState = undefined; + component.close(); + expect(component.animationState).toEqual('hidden'); + + // Else branch: + component.isOpen = false; + component.animationState = undefined; component.close(); - expect(component['lastCaller']).toBeUndefined(); - expect(component.isOpen).toEqual(false); + expect(component.animationState).toBeUndefined(); }); it('should remove a CSS classname before the animation starts', () => { - const spy = spyOn(component['adapterService'], 'showPopover').and.returnValue(0); + const spy = spyOn(mockAdapterService, 'showPopover').and.returnValue(undefined); component.onAnimationStart({ fromState: 'hidden', @@ -127,7 +138,7 @@ describe('SkyPopoverComponent', () => { }); it('should add a CSS classname when the animation stops', () => { - const spy = spyOn(component['adapterService'], 'hidePopover').and.returnValue(0); + const spy = spyOn(mockAdapterService, 'hidePopover').and.returnValue(undefined); component.onAnimationDone({ fromState: 'visible', @@ -171,15 +182,6 @@ describe('SkyPopoverComponent', () => { expect(spy).toHaveBeenCalledWith(component); }); - it('should get the animation state', () => { - let state = component.getAnimationState(); - expect(state).toEqual('hidden'); - component.isOpen = true; - fixture.detectChanges(); - state = component.getAnimationState(); - expect(state).toEqual('visible'); - }); - it('should capture mouse enter and mouse leave events', () => { expect(component['isMouseEnter']).toEqual(false); TestUtility.fireDomEvent(fixture.nativeElement, 'mouseenter'); @@ -188,42 +190,37 @@ describe('SkyPopoverComponent', () => { expect(component['isMouseEnter']).toEqual(false); }); - it('should adjust placement on window resize', () => { - component.placement = 'below'; - spyOn(component, 'positionNextTo').and.returnValue(0); - TestUtility.fireDomEvent(window, 'resize'); - expect(component.positionNextTo).toHaveBeenCalledWith(component['lastCaller'], 'below'); - }); - it('should close the popover when the escape key is pressed', () => { - spyOn(component, 'close'); + const spy = spyOn(fixture.componentInstance, 'close'); - component.isOpen = true; + fixture.componentInstance.isOpen = true; - const escapeEvent: any = document.createEvent('CustomEvent'); - escapeEvent.which = 27; - escapeEvent.initEvent('keyup', true, true); - document.dispatchEvent(escapeEvent); + TestUtility.fireKeyboardEvent(fixture.nativeElement, 'keyup', { key: 'Escape' }); + expect(spy).toHaveBeenCalledWith(); - fixture.detectChanges(); - const element = fixture.debugElement.query(By.css('.sky-popover-container')); + spy.calls.reset(); - expect(component.close).toHaveBeenCalled(); - expect(element.nativeElement).toBeDefined(); + // Should ignore other key events. + TestUtility.fireKeyboardEvent(fixture.nativeElement, 'keyup', { key: 'Backspace' }); + expect(spy).not.toHaveBeenCalled(); }); - it('should only close the popover (when escape pressed) if it is open', () => { - spyOn(component, 'close'); - - component.isOpen = false; + it('should reposition the popover on window scroll', () => { + const spy = spyOn(fixture.componentInstance, 'positionNextTo'); + const event = document.createEvent('CustomEvent'); + event.initEvent('scroll', false, false); - const escapeEvent: any = document.createEvent('CustomEvent'); - escapeEvent.which = 27; - escapeEvent.initEvent('keyup', true, true); - document.dispatchEvent(escapeEvent); + component.isOpen = true; + window.dispatchEvent(event); + fixture.detectChanges(); + expect(spy).toHaveBeenCalled(); + // Test the else condition. + spy.calls.reset(); + component.isOpen = false; + window.dispatchEvent(event); fixture.detectChanges(); - expect(component.close).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalled(); }); it('should close the popover when the document is clicked', () => { @@ -236,6 +233,17 @@ describe('SkyPopoverComponent', () => { expect(component.close).toHaveBeenCalled(); }); + it('should allow disabling of closing the popover when the document is clicked', () => { + spyOn(component, 'close'); + + component.isOpen = true; + component.dismissOnBlur = false; + TestUtility.fireDomEvent(document, 'click'); + + fixture.detectChanges(); + expect(component.close).not.toHaveBeenCalled(); + }); + it('should not close the popover if the popover is clicked', () => { spyOn(component, 'close'); diff --git a/src/modules/popover/popover.component.ts b/src/modules/popover/popover.component.ts index a613a8e95..b24647ffc 100644 --- a/src/modules/popover/popover.component.ts +++ b/src/modules/popover/popover.component.ts @@ -6,6 +6,8 @@ import { EventEmitter, HostListener, Input, + OnDestroy, + OnInit, Output, ViewChild } from '@angular/core'; @@ -19,8 +21,18 @@ import { transition } from '@angular/animations'; -import { SkyWindowRefService } from '../window'; -import { SkyPopoverPlacement, SkyPopoverAdapterService } from './index'; +import { Subject } from 'rxjs/Subject'; + +import { + SkyWindowRefService +} from '../window'; + +import { + SkyPopoverAlignment, + SkyPopoverPlacement +} from './types'; + +import { SkyPopoverAdapterService } from './popover-adapter.service'; @Component({ selector: 'sky-popover', @@ -37,57 +49,105 @@ import { SkyPopoverPlacement, SkyPopoverAdapterService } from './index'; ], changeDetection: ChangeDetectionStrategy.OnPush }) -export class SkyPopoverComponent { +export class SkyPopoverComponent implements OnInit, OnDestroy { + @Input() + public dismissOnBlur = true; + @Input() public popoverTitle: string; @Input() - public placement: SkyPopoverPlacement; + public set alignment(value: SkyPopoverAlignment) { + this._alignment = value; + } + + public get alignment(): SkyPopoverAlignment { + return this._alignment || 'center'; + } + + @Input() + public set placement(value: SkyPopoverPlacement) { + this._placement = value; + } + + public get placement(): SkyPopoverPlacement { + return this._placement || 'above'; + } @Output() - public popoverOpened: EventEmitter; + public popoverOpened = new EventEmitter(); @Output() - public popoverClosed: EventEmitter; + public popoverClosed = new EventEmitter(); @ViewChild('popoverContainer') public popoverContainer: ElementRef; @ViewChild('popoverArrow') public popoverArrow: ElementRef; + public isOpen = false; - public placementClassName: string; public isMouseEnter = false; + public classNames: string[] = []; + public animationState: 'hidden' | 'visible' = 'hidden'; + + public popoverTop: number; + public popoverLeft: number; + public arrowTop: number; + public arrowLeft: number; private isMarkedForCloseOnMouseLeave = false; - private lastCaller: ElementRef; - private readonly placementDefault: SkyPopoverPlacement = 'above'; + private caller: ElementRef; + private destroy = new Subject(); + + private _alignment: SkyPopoverAlignment; + private _placement: SkyPopoverPlacement; constructor( - private windowRef: SkyWindowRefService, + private adapterService: SkyPopoverAdapterService, private changeDetector: ChangeDetectorRef, - private adapterService: SkyPopoverAdapterService - ) { - this.placement = this.placementDefault; - this.popoverOpened = new EventEmitter(); - this.popoverClosed = new EventEmitter(); + private elementRef: ElementRef, + private windowRef: SkyWindowRefService + ) { } + + public ngOnInit() { + this.adapterService.hidePopover(this.popoverContainer); + this.updateClassNames(); } - @HostListener('window:resize') - public adjustOnResize() { - this.positionNextTo(this.lastCaller, this.placement); + public ngOnDestroy() { + this.destroy.next(true); + this.destroy.unsubscribe(); + } + + @HostListener('keyup', ['$event']) + public onKeyUp(event: KeyboardEvent): void { + const key = event.key.toLowerCase(); + if (key === 'escape' && this.isOpen) { + event.stopPropagation(); + event.preventDefault(); + this.close(); + if (this.caller) { + this.caller.nativeElement.focus(); + } + } } - @HostListener('document:keyup', ['$event']) - public closeOnEscapeKeyPressed(event: KeyboardEvent): void { - if (this.isOpen && event.which === 27) { + @HostListener('document:focusin', ['$event']) + public onFocusIn(event: KeyboardEvent) { + const targetIsChild = (this.elementRef.nativeElement.contains(event.target)); + const targetIsCaller = (this.caller && this.caller.nativeElement === event.target); + + if (!targetIsChild && !targetIsCaller && this.isOpen && this.dismissOnBlur) { + // The popover is open, is currently being operated by the user, and + // has just lost keyboard focus. We should close it. this.close(); } } @HostListener('document:click', ['$event']) - public closeOnDocumentClick(event: MouseEvent): void { - if (this.isOpen && !this.isMouseEnter) { + public onDocumentClick(event: MouseEvent): void { + if (!this.isMouseEnter && this.dismissOnBlur) { this.close(); } } @@ -100,42 +160,71 @@ export class SkyPopoverComponent { @HostListener('mouseleave') public onMouseLeave() { this.isMouseEnter = false; - if (this.isMarkedForCloseOnMouseLeave) { this.close(); this.isMarkedForCloseOnMouseLeave = false; } } - public positionNextTo(caller: ElementRef, placement: SkyPopoverPlacement) { + @HostListener('window:scroll') + public onWindowScroll() { + if (this.isOpen) { + this.positionNextTo(this.caller, this.placement, this.alignment); + } + } + + public positionNextTo( + caller: ElementRef, + placement?: SkyPopoverPlacement, + alignment?: SkyPopoverAlignment + ) { if (!caller) { return; } - this.lastCaller = caller; + if (placement !== this.placement) { + this.updateClassNames(placement, alignment); + this.changeDetector.markForCheck(); + } + + this.caller = caller; + this.placement = placement; + this.alignment = alignment; const elements = { popover: this.popoverContainer, popoverArrow: this.popoverArrow, - caller: this.lastCaller + caller: this.caller }; - this.placement = placement || this.placementDefault; - this.changeDetector.markForCheck(); - - // Wait for a tick to allow placement styles to render. - // (The styles affect the element dimensions.) + // Let the styles render before gauging the dimensions. this.windowRef.getWindow().setTimeout(() => { - this.adapterService.setPopoverPosition(elements, this.placement); - this.isOpen = true; + const position = this.adapterService.getPopoverPosition( + elements, + this.placement, + this.alignment + ); + + this.updateClassNames(position.placement, position.alignment); + + this.popoverTop = position.top; + this.popoverLeft = position.left; + this.arrowTop = position.arrowTop; + this.arrowLeft = position.arrowLeft; + this.animationState = 'visible'; this.changeDetector.markForCheck(); }); } + public resetPopover() { + this.adapterService.clearConcreteDimensions(this.popoverContainer); + } + public close() { - this.lastCaller = undefined; - this.isOpen = false; - this.changeDetector.markForCheck(); + if (this.isOpen) { + this.animationState = 'hidden'; + this.changeDetector.markForCheck(); + } } public onAnimationStart(event: AnimationEvent) { @@ -154,18 +243,23 @@ export class SkyPopoverComponent { } if (event.toState === 'hidden') { + this.isOpen = false; this.adapterService.hidePopover(this.popoverContainer); this.popoverClosed.emit(this); } else { + this.isOpen = true; this.popoverOpened.emit(this); } } - public getAnimationState(): string { - return (this.isOpen) ? 'visible' : 'hidden'; - } - public markForCloseOnMouseLeave() { this.isMarkedForCloseOnMouseLeave = true; } + + public updateClassNames(placement?: SkyPopoverPlacement, alignment?: SkyPopoverAlignment) { + this.classNames = [ + `sky-popover-alignment-${alignment || this.alignment}`, + `sky-popover-placement-${placement || this.placement}` + ]; + } } diff --git a/src/modules/popover/popover.directive.spec.ts b/src/modules/popover/popover.directive.spec.ts index 58cd15a64..e01d76c3c 100644 --- a/src/modules/popover/popover.directive.spec.ts +++ b/src/modules/popover/popover.directive.spec.ts @@ -1,5 +1,3 @@ -import { By } from '@angular/platform-browser'; - import { DebugElement } from '@angular/core'; @@ -9,10 +7,18 @@ import { TestBed } from '@angular/core/testing'; +import { + By +} from '@angular/platform-browser'; + import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { + TestUtility +} from '../testing'; + import { SkyWindowRefService } from '../window'; @@ -23,9 +29,7 @@ import { SkyPopoverAdapterService } from './index'; -import { - SkyPopoverTestComponent -} from './fixtures/popover.component.fixture'; +import { SkyPopoverTestComponent } from './fixtures/popover.component.fixture'; class MockWindowService { public getWindow(): any { @@ -39,16 +43,13 @@ class MockWindowService { describe('SkyPopoverDirective', () => { let fixture: ComponentFixture; - let component: SkyPopoverTestComponent; let directiveElements: DebugElement[]; + let mockWindowService: MockWindowService; - function triggerMouseEvent(el: DebugElement, eventName: string) { - el.triggerEventHandler( - eventName, - { - preventDefault: () => {} - } - ); + function triggerMouseEvent(el: Element, eventName: string) { + const event: any = document.createEvent('CustomEvent'); + event.initEvent(eventName, true, true); + el.dispatchEvent(event); } function validateTriggerOpensPopover( @@ -56,62 +57,42 @@ describe('SkyPopoverDirective', () => { openTrigger: string, closeTrigger: string ) { - const allOpenTriggers = [ - 'click', - 'mouseenter' - ]; - - const allCloseTriggers = [ - 'click', - 'mouseleave' - ]; - const caller = directiveElements[elIndex]; const callerInstance = caller.injector.get(SkyPopoverDirective); const positionNextToSpy = spyOn(callerInstance.skyPopover, 'positionNextTo'); const closeSpy = spyOn(callerInstance.skyPopover, 'close'); - // The popover shouldn't be opened on other triggers. - for (const supportedTrigger of allOpenTriggers) { - if (supportedTrigger !== openTrigger) { - triggerMouseEvent(caller, supportedTrigger); - - expect(positionNextToSpy).not.toHaveBeenCalled(); - } + // The popover should only execute hover events if it is set to 'mouseenter'. + if (openTrigger !== 'mouseenter') { + triggerMouseEvent(caller.nativeElement, 'mouseenter'); + expect(positionNextToSpy).not.toHaveBeenCalled(); } - triggerMouseEvent(caller, openTrigger); - + triggerMouseEvent(caller.nativeElement, openTrigger); expect(positionNextToSpy).toHaveBeenCalled(); callerInstance.skyPopover.isOpen = true; - // The popover shouldn't be closed on other triggers. - for (const supportedCloseTrigger of allCloseTriggers) { - if (supportedCloseTrigger !== closeTrigger) { - triggerMouseEvent(caller, supportedCloseTrigger); - - expect(closeSpy).not.toHaveBeenCalled(); - } + // The popover should only execute hover events if it is set to 'mouseenter'. + if (closeTrigger !== 'mouseleave') { + triggerMouseEvent(caller.nativeElement, 'mouseleave'); + expect(closeSpy).not.toHaveBeenCalled(); } - triggerMouseEvent(caller, closeTrigger); - + triggerMouseEvent(caller.nativeElement, closeTrigger); expect(closeSpy).toHaveBeenCalled(); // Make sure close isn't called again when the popover is already closed. closeSpy.calls.reset(); - callerInstance.skyPopover.isOpen = false; - triggerMouseEvent(caller, closeTrigger); - + triggerMouseEvent(caller.nativeElement, closeTrigger); expect(closeSpy).not.toHaveBeenCalled(); } beforeEach(() => { - let mockWindowService = new MockWindowService(); + mockWindowService = new MockWindowService(); let mockAdapterService = {}; TestBed.configureTestingModule({ @@ -131,7 +112,6 @@ describe('SkyPopoverDirective', () => { .compileComponents(); fixture = TestBed.createComponent(SkyPopoverTestComponent); - component = fixture.componentInstance; directiveElements = fixture.debugElement.queryAll(By.directive(SkyPopoverDirective)); fixture.detectChanges(); }); @@ -139,37 +119,26 @@ describe('SkyPopoverDirective', () => { it('should ask the popover to position itself accordingly', () => { const caller = directiveElements[0]; const callerInstance = caller.injector.get(SkyPopoverDirective); - spyOn(callerInstance.skyPopover, 'positionNextTo'); - - triggerMouseEvent(caller, 'click'); - - expect(callerInstance.skyPopover.positionNextTo) - .toHaveBeenCalledWith(callerInstance.elementRef, undefined); + const spy = spyOn(callerInstance.skyPopover, 'positionNextTo'); + caller.nativeElement.click(); + expect(spy).toHaveBeenCalledWith(callerInstance['elementRef'], undefined, undefined); }); it('should ask the popover to close itself if the button is clicked again', () => { const caller = directiveElements[0]; const callerInstance = caller.injector.get(SkyPopoverDirective); - + const spy = spyOn(callerInstance.skyPopover, 'close'); callerInstance.skyPopover.isOpen = true; - spyOn(callerInstance.skyPopover, 'close'); - - triggerMouseEvent(caller, 'click'); - - expect(callerInstance.skyPopover.close) - .toHaveBeenCalledWith(); + caller.nativeElement.click(); + expect(spy).toHaveBeenCalledWith(); }); it('should pass along the placement', () => { const caller = directiveElements[1]; const callerInstance = caller.injector.get(SkyPopoverDirective); - - spyOn(callerInstance.skyPopover, 'positionNextTo'); - - triggerMouseEvent(caller, 'click'); - - expect(callerInstance.skyPopover.positionNextTo) - .toHaveBeenCalledWith(callerInstance.elementRef, 'below'); + const spy = spyOn(callerInstance.skyPopover, 'positionNextTo'); + caller.nativeElement.click(); + expect(spy).toHaveBeenCalledWith(callerInstance['elementRef'], 'below', undefined); }); it('should allow click to display the popover', () => { @@ -183,10 +152,84 @@ describe('SkyPopoverDirective', () => { it('should mark the popover to close on mouseleave', () => { const caller = directiveElements[2]; const callerInstance = caller.injector.get(SkyPopoverDirective); - const spy = spyOn(callerInstance.skyPopover, 'markForCloseOnMouseLeave'); + const popoverSpy = spyOn(callerInstance.skyPopover, 'markForCloseOnMouseLeave'); + const closeSpy = spyOn((callerInstance as any), 'closePopover').and.callThrough(); + callerInstance.skyPopover.isOpen = true; callerInstance.skyPopover.isMouseEnter = true; - triggerMouseEvent(caller, 'mouseleave'); + + triggerMouseEvent(caller.nativeElement, 'mouseleave'); + expect(popoverSpy).not.toHaveBeenCalled(); + expect(closeSpy).toHaveBeenCalled(); + + popoverSpy.calls.reset(); + closeSpy.calls.reset(); + + // Else path, popover has mouseenter. + callerInstance.skyPopover.isOpen = true; + spyOn(mockWindowService, 'getWindow').and.returnValue({ + setTimeout(callback: Function) { + // Simulate the popover triggering mouseenter event: + callerInstance.skyPopover.isMouseEnter = true; + callback(); + } + }); + + triggerMouseEvent(caller.nativeElement, 'mouseleave'); + expect(popoverSpy).toHaveBeenCalledWith(); + expect(closeSpy).not.toHaveBeenCalled(); + }); + + it('should adjust placement on window resize', () => { + const caller = directiveElements[3]; + const callerInstance = caller.injector.get(SkyPopoverDirective); + const spy = spyOn(callerInstance.skyPopover, 'positionNextTo'); + + callerInstance.skyPopover.isOpen = false; + caller.nativeElement.click(); + + expect(spy).toHaveBeenCalledWith(callerInstance['elementRef'], 'above', 'left'); + callerInstance.skyPopover.isOpen = true; + + spy.calls.reset(); + + TestUtility.fireDomEvent(window, 'resize'); + expect(spy).toHaveBeenCalledWith(callerInstance['elementRef'], 'above', 'left'); + + // Positioning should only occur if the popover is open. + caller.nativeElement.click(); + callerInstance.skyPopover.isOpen = false; + spy.calls.reset(); + + TestUtility.fireDomEvent(window, 'resize'); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should close the popover when the escape key is pressed', () => { + const caller = directiveElements[3]; + const callerInstance = caller.injector.get(SkyPopoverDirective); + const spy = spyOn(callerInstance.skyPopover, 'close'); + + callerInstance.skyPopover.isOpen = true; + + TestUtility.fireKeyboardEvent(caller.nativeElement, 'keyup', { key: 'Escape' }); + expect(spy).toHaveBeenCalledWith(); + + spy.calls.reset(); + + // Should ignore other key events. + TestUtility.fireKeyboardEvent(caller.nativeElement, 'keyup', { key: 'Backspace' }); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should focus the caller element after being closed', () => { + const caller = directiveElements[3]; + const callerInstance = caller.injector.get(SkyPopoverDirective); + const spy = spyOn(callerInstance['elementRef'].nativeElement, 'focus').and.callThrough(); + + callerInstance.skyPopover.isOpen = true; + + TestUtility.fireKeyboardEvent(caller.nativeElement, 'keyup', { key: 'Escape' }); expect(spy).toHaveBeenCalledWith(); }); }); diff --git a/src/modules/popover/popover.directive.ts b/src/modules/popover/popover.directive.ts index 9dd7d4dae..4d5f03913 100644 --- a/src/modules/popover/popover.directive.ts +++ b/src/modules/popover/popover.directive.ts @@ -10,10 +10,12 @@ import { } from '../window'; import { - SkyPopoverComponent, + SkyPopoverAlignment, SkyPopoverPlacement, SkyPopoverTrigger -} from './index'; +} from './types'; + +import { SkyPopoverComponent } from './popover.component'; @Directive({ selector: '[skyPopover]' @@ -22,6 +24,9 @@ export class SkyPopoverDirective { @Input() public skyPopover: SkyPopoverComponent; + @Input() + public skyPopoverAlignment: SkyPopoverAlignment; + @Input() public skyPopoverPlacement: SkyPopoverPlacement; @@ -29,35 +34,54 @@ export class SkyPopoverDirective { public skyPopoverTrigger: SkyPopoverTrigger = 'click'; constructor( - public elementRef: ElementRef, + private elementRef: ElementRef, private windowRef: SkyWindowRefService ) { } - @HostListener('click', ['$event']) - public togglePopover(event: MouseEvent) { - if (this.skyPopoverTrigger === 'click') { + @HostListener('window:resize') + public onWindowResize() { + if (this.skyPopover.isOpen) { + this.positionPopover(); + } + } + + @HostListener('keyup', ['$event']) + public onDocumentKeyUp(event: KeyboardEvent): void { + const key = event.key.toLowerCase(); + if (key === 'escape' && this.skyPopover.isOpen) { + event.stopPropagation(); event.preventDefault(); + this.closePopover(); + this.elementRef.nativeElement.focus(); + } + } - if (this.skyPopover.isOpen) { - this.skyPopover.close(); - return; - } + @HostListener('click', ['$event']) + public togglePopover(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); - this.skyPopover.positionNextTo(this.elementRef, this.skyPopoverPlacement); + if (this.skyPopover.isOpen) { + this.closePopover(); + return; } + + this.positionPopover(); } @HostListener('mouseenter', ['$event']) public onMouseEnter(event: MouseEvent) { + this.skyPopover.isMouseEnter = true; if (this.skyPopoverTrigger === 'mouseenter') { event.preventDefault(); - - this.skyPopover.positionNextTo(this.elementRef, this.skyPopoverPlacement); + this.positionPopover(); } } @HostListener('mouseleave', ['$event']) public onMouseLeave(event: MouseEvent) { + this.skyPopover.isMouseEnter = false; + if (this.skyPopoverTrigger === 'mouseenter') { event.preventDefault(); @@ -68,10 +92,22 @@ export class SkyPopoverDirective { if (this.skyPopover.isMouseEnter) { this.skyPopover.markForCloseOnMouseLeave(); } else { - this.skyPopover.close(); + this.closePopover(); } } }); } } + + private positionPopover() { + this.skyPopover.positionNextTo( + this.elementRef, + this.skyPopoverPlacement, + this.skyPopoverAlignment + ); + } + + private closePopover() { + this.skyPopover.close(); + } } diff --git a/src/modules/popover/popover.module.ts b/src/modules/popover/popover.module.ts index de61dbb09..98a3e95df 100644 --- a/src/modules/popover/popover.module.ts +++ b/src/modules/popover/popover.module.ts @@ -1,6 +1,14 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { + BrowserAnimationsModule +} from '@angular/platform-browser/animations'; + +import { + SkyWindowRefService +} from '../window'; + import { SkyPopoverComponent, SkyPopoverDirective @@ -12,11 +20,15 @@ import { SkyPopoverDirective ], imports: [ + BrowserAnimationsModule, CommonModule ], exports: [ SkyPopoverComponent, SkyPopoverDirective + ], + providers: [ + SkyWindowRefService ] }) export class SkyPopoverModule { } diff --git a/src/modules/popover/types/index.ts b/src/modules/popover/types/index.ts new file mode 100644 index 000000000..181c37065 --- /dev/null +++ b/src/modules/popover/types/index.ts @@ -0,0 +1,8 @@ +export * from './popover-adapter-arrow-coordinates'; +export * from './popover-adapter-coordinates'; +export * from './popover-adapter-elements'; +export * from './popover-adapter-parent-dimensions'; +export * from './popover-alignment'; +export * from './popover-placement'; +export * from './popover-trigger'; +export * from './popover-position'; diff --git a/src/modules/popover/types/popover-adapter-arrow-coordinates.ts b/src/modules/popover/types/popover-adapter-arrow-coordinates.ts new file mode 100644 index 000000000..4b62c21f5 --- /dev/null +++ b/src/modules/popover/types/popover-adapter-arrow-coordinates.ts @@ -0,0 +1,4 @@ +export interface SkyPopoverAdapterArrowCoordinates { + top: number; + left: number; +} diff --git a/src/modules/popover/types/popover-adapter-coordinates.ts b/src/modules/popover/types/popover-adapter-coordinates.ts new file mode 100644 index 000000000..56d4c77c2 --- /dev/null +++ b/src/modules/popover/types/popover-adapter-coordinates.ts @@ -0,0 +1,5 @@ +export interface SkyPopoverAdapterCoordinates { + isOutsideViewport: boolean; + top?: number; + left?: number; +} diff --git a/src/modules/popover/types/popover-adapter-elements.ts b/src/modules/popover/types/popover-adapter-elements.ts new file mode 100644 index 000000000..003267354 --- /dev/null +++ b/src/modules/popover/types/popover-adapter-elements.ts @@ -0,0 +1,9 @@ +import { + ElementRef +} from '@angular/core'; + +export interface SkyPopoverAdapterElements { + popover: ElementRef; + popoverArrow: ElementRef; + caller: ElementRef; +} diff --git a/src/modules/popover/types/popover-adapter-parent-dimensions.ts b/src/modules/popover/types/popover-adapter-parent-dimensions.ts new file mode 100644 index 000000000..b727d04ac --- /dev/null +++ b/src/modules/popover/types/popover-adapter-parent-dimensions.ts @@ -0,0 +1,4 @@ +export interface SkyPopoverAdapterParentDimensions { + width: number; + height: number; +} diff --git a/src/modules/popover/types/popover-alignment.ts b/src/modules/popover/types/popover-alignment.ts new file mode 100644 index 000000000..f1df24386 --- /dev/null +++ b/src/modules/popover/types/popover-alignment.ts @@ -0,0 +1 @@ +export type SkyPopoverAlignment = 'left' | 'center' | 'right'; diff --git a/src/modules/popover/popover-placement.ts b/src/modules/popover/types/popover-placement.ts similarity index 73% rename from src/modules/popover/popover-placement.ts rename to src/modules/popover/types/popover-placement.ts index 67a1e993c..5edac62a7 100644 --- a/src/modules/popover/popover-placement.ts +++ b/src/modules/popover/types/popover-placement.ts @@ -1 +1 @@ -export type SkyPopoverPlacement = 'above' | 'below' | 'right' | 'left'; +export type SkyPopoverPlacement = 'above' | 'below' | 'right' | 'left' | 'fullscreen'; diff --git a/src/modules/popover/types/popover-position.ts b/src/modules/popover/types/popover-position.ts new file mode 100644 index 000000000..5239ee69c --- /dev/null +++ b/src/modules/popover/types/popover-position.ts @@ -0,0 +1,11 @@ +import { SkyPopoverAlignment } from './popover-alignment'; +import { SkyPopoverPlacement } from './popover-placement'; + +export interface SkyPopoverPosition { + top: number; + left: number; + arrowTop: number; + arrowLeft: number; + placement: SkyPopoverPlacement; + alignment?: SkyPopoverAlignment; +} diff --git a/src/modules/popover/popover-trigger.ts b/src/modules/popover/types/popover-trigger.ts similarity index 100% rename from src/modules/popover/popover-trigger.ts rename to src/modules/popover/types/popover-trigger.ts diff --git a/src/modules/sort/sort.component.html b/src/modules/sort/sort.component.html index 6773704a7..f59f0b393 100644 --- a/src/modules/sort/sort.component.html +++ b/src/modules/sort/sort.component.html @@ -1,8 +1,9 @@
    - + [title]="'sort_button_label' | skyResources" + [messageStream]="dropdownController"> +
    {{'sort_menu_heading' | skyResources}}
    diff --git a/src/modules/sort/sort.component.spec.ts b/src/modules/sort/sort.component.spec.ts index f111025ff..0a33bbd1d 100644 --- a/src/modules/sort/sort.component.spec.ts +++ b/src/modules/sort/sort.component.spec.ts @@ -1,6 +1,8 @@ import { + ComponentFixture, + fakeAsync, TestBed, - ComponentFixture + tick } from '@angular/core/testing'; import { @@ -34,7 +36,6 @@ describe('Sort component', () => { fixture = TestBed.createComponent(SortTestComponent); nativeElement = fixture.nativeElement as HTMLElement; component = fixture.componentInstance; - fixture.detectChanges(); }); function getDropdownButtonEl() { @@ -47,32 +48,43 @@ describe('Sort component', () => { return nativeElement.querySelectorAll(itemQuery); } - it('creates a sort dropdown that respects active input', () => { + it('creates a sort dropdown that respects active input', fakeAsync(() => { + fixture.detectChanges(); + tick(); let dropdownButtonEl = getDropdownButtonEl(); expect(dropdownButtonEl).not.toBeNull(); + dropdownButtonEl.click(); + tick(); fixture.detectChanges(); - let menuHeaderQuery = '.sky-sort .sky-sort-menu-heading'; + tick(); + + let menuHeaderQuery = '.sky-sort-menu-heading'; expect(nativeElement.querySelector(menuHeaderQuery)).toHaveText('Sort by'); let itemsEl = getSortItems(); expect(itemsEl.length).toBe(6); expect(itemsEl.item(2)).toHaveCssClass('sky-sort-item-selected'); expect(itemsEl.item(2)).toHaveText('Date created (newest first)'); + })); - }); - - it('changes active item on click and emits proper event', () => { + it('changes active item on click and emits proper event', fakeAsync(() => { + fixture.detectChanges(); let dropdownButtonEl = getDropdownButtonEl(); dropdownButtonEl.click(); + tick(); fixture.detectChanges(); + tick(); let itemsEl = getSortItems(); let clickItem = itemsEl.item(1).querySelector('button') as HTMLElement; + clickItem.click(); + tick(); fixture.detectChanges(); + tick(); - expect(component.sortedItem).toEqual( { + expect(component.sortedItem).toEqual({ id: 2, label: 'Assigned to (Z - A)', name: 'assignee', @@ -81,9 +93,10 @@ describe('Sort component', () => { itemsEl = getSortItems(); expect(itemsEl.item(1)).toHaveCssClass('sky-sort-item-selected'); - }); + })); it('can set active input programmatically', () => { + fixture.detectChanges(); component.initialState = 4; fixture.detectChanges(); let itemsEl = getSortItems(); diff --git a/src/modules/sort/sort.component.ts b/src/modules/sort/sort.component.ts index f5f2d326e..6dc55206e 100644 --- a/src/modules/sort/sort.component.ts +++ b/src/modules/sort/sort.component.ts @@ -3,6 +3,13 @@ import { ChangeDetectionStrategy } from '@angular/core'; +import { Subject } from 'rxjs/Subject'; + +import { + SkyDropdownMessage, + SkyDropdownMessageType +} from '../dropdown'; + import { SkySortService } from './sort.service'; @@ -17,5 +24,11 @@ import { changeDetection: ChangeDetectionStrategy.OnPush }) export class SkySortComponent { + public dropdownController = new Subject(); + public dropdownClicked() { + this.dropdownController.next({ + type: SkyDropdownMessageType.Close + }); + } } diff --git a/src/modules/tabs/fixtures/tabs-fixtures.module.ts b/src/modules/tabs/fixtures/tabs-fixtures.module.ts index 5d48e5634..244e06691 100644 --- a/src/modules/tabs/fixtures/tabs-fixtures.module.ts +++ b/src/modules/tabs/fixtures/tabs-fixtures.module.ts @@ -1,6 +1,7 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { SkyModalModule } from '../../modal'; @@ -19,7 +20,8 @@ import { TabsetActiveTestComponent } from './tabset-active.component.fixture'; CommonModule, FormsModule, SkyModalModule, - SkyTabsModule + SkyTabsModule, + NoopAnimationsModule ], exports: [ TabsetTestComponent, diff --git a/src/modules/tabs/tab-dropdown.component.html b/src/modules/tabs/tab-dropdown.component.html index 0a1910052..bb67afc99 100644 --- a/src/modules/tabs/tab-dropdown.component.html +++ b/src/modules/tabs/tab-dropdown.component.html @@ -1,33 +1,30 @@ - - - {{activeTabHeading}} - - -
    - - -
    -
    -
    +
    + + + {{ activeTabHeading }} + + + +
    + + +
    +
    +
    +
    +
    diff --git a/src/modules/tabs/tab-dropdown.component.scss b/src/modules/tabs/tab-dropdown.component.scss index 7e1c6fe45..df731c3ef 100644 --- a/src/modules/tabs/tab-dropdown.component.scss +++ b/src/modules/tabs/tab-dropdown.component.scss @@ -1,4 +1,10 @@ -@import "../../scss/mixins"; +@import '../../scss/mixins'; + +.sky-tab-dropdown { + ::ng-deep .sky-dropdown-item { + margin: 0; + } +} .sky-tab-dropdown-button { max-width: 300px; @@ -8,7 +14,7 @@ display: flex; padding: 0; transition: background-color $sky-transition-time-short; - width: 300px; + min-width: 300px; &:hover:not(.sky-tab-dropdown-item-selected) { background-color: $sky-background-color-neutral-light; diff --git a/src/modules/tabs/tabset.component.spec.ts b/src/modules/tabs/tabset.component.spec.ts index b9935ba64..658369cfd 100644 --- a/src/modules/tabs/tabset.component.spec.ts +++ b/src/modules/tabs/tabset.component.spec.ts @@ -378,19 +378,17 @@ describe('Tabset component', () => { mockAdapterService = new MockTabsetAdapterService(); mockAdapterService.disableDetectOverflow = true; - fixture = TestBed - .overrideComponent(SkyTabsetComponent, { - set: { - providers: [ - SkyTabsetService, - { - provide: SkyTabsetAdapterService, - useValue: mockAdapterService - } - ] + fixture = TestBed.overrideComponent(SkyTabsetComponent, { + set: { + providers: [ + SkyTabsetService, + { + provide: SkyTabsetAdapterService, + useValue: mockAdapterService } - }) - .createComponent(TabsetTestComponent); + ] + } + }).createComponent(TabsetTestComponent); }); it( @@ -416,9 +414,7 @@ describe('Tabset component', () => { } ); - it( - 'should allow another tab to be selected from the dropdown', - fakeAsync(() => { + it('should allow another tab to be selected from the dropdown', fakeAsync(() => { let el = fixture.nativeElement; fixture.detectChanges(); @@ -432,10 +428,15 @@ describe('Tabset component', () => { let tabEl = el.querySelector('.sky-dropdown-button-type-tab'); tabEl.click(); + tick(); + fixture.detectChanges(); + tick(); + let dropdownTabButtons = el.querySelectorAll('.sky-tab-dropdown-item-btn'); expect(dropdownTabButtons[1]).toHaveText('Tab 2'); - dropdownTabButtons[1].click(); + dropdownTabButtons[1].click(); + tick(); fixture.detectChanges(); tick(); @@ -461,27 +462,31 @@ describe('Tabset component', () => { let tabEl = el.querySelector('.sky-dropdown-button-type-tab'); tabEl.click(); + tick(); + fixture.detectChanges(); + tick(); + let dropdownTabButtons = el.querySelectorAll('.sky-tab-dropdown-item-btn'); dropdownTabButtons[0].click(); - + tick(); fixture.detectChanges(); tick(); tabEl.click(); - + tick(); fixture.detectChanges(); tick(); expect(dropdownTabButtons[1]).toHaveText('Tab 2'); expect(dropdownTabButtons[1]).toHaveCssClass('sky-btn-disabled'); - dropdownTabButtons[1].click(); + dropdownTabButtons[1].click(); + tick(); fixture.detectChanges(); tick(); validateTabSelected(el, 0); - } )); diff --git a/src/modules/testing/index.ts b/src/modules/testing/index.ts index 53f319a25..19c683c34 100644 --- a/src/modules/testing/index.ts +++ b/src/modules/testing/index.ts @@ -1 +1,2 @@ -export { expect } from './matchers'; +export * from './matchers'; +export * from './testutility'; diff --git a/src/modules/testing/testutility.ts b/src/modules/testing/testutility.ts index efa7c59db..264de42ea 100644 --- a/src/modules/testing/testutility.ts +++ b/src/modules/testing/testutility.ts @@ -1,11 +1,37 @@ -function createEvent(eventName: string) { - let evt = document.createEvent('CustomEvent'); - evt.initEvent(eventName, false, false); - return evt; +export interface SkyTestUtilityEventArgs { + bubbles?: boolean; + cancelable?: boolean; +} + +export interface SkyTestUtilityKeyboardEventArgs { + key?: string; +} + +function createEvent(eventName: string, args?: SkyTestUtilityEventArgs): Event { + args = Object.assign({ + bubbles: false, + cancelable: false + }, args); + + const event = document.createEvent('CustomEvent'); + event.initEvent(eventName, args.bubbles, args.cancelable); + return event; } export class TestUtility { - public static fireDomEvent(el: any, name: string) { - el.dispatchEvent(createEvent(name)); + public static fireDomEvent(element: EventTarget, name: string, args: any = {}) { + const event = createEvent(name, args); + element.dispatchEvent(event); + } + + public static fireKeyboardEvent(element: EventTarget, name: string, args: KeyboardEventInit = {}) { + let event = createEvent(name, { + cancelable: true, + bubbles: true + }) as any; + + event = Object.assign(event, args); + + element.dispatchEvent(event as KeyboardEvent); } } diff --git a/src/modules/timepicker/timepicker-component.spec.ts b/src/modules/timepicker/timepicker-component.spec.ts index aac6a305c..a33023d0e 100644 --- a/src/modules/timepicker/timepicker-component.spec.ts +++ b/src/modules/timepicker/timepicker-component.spec.ts @@ -1,4 +1,5 @@ import { + flush, TestBed, ComponentFixture, fakeAsync, @@ -6,23 +7,25 @@ import { } from '@angular/core/testing'; import { - FormsModule, - NgModel + FormsModule } from '@angular/forms'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + import { SkyTimepickerModule } from './timepicker.module'; import { TimepickerTestComponent } from './fixtures/timepicker-component.fixture'; import { expect } from '../testing'; -import { By } from '@angular/platform-browser'; + let moment = require('moment'); describe('Timepicker', () => { - function openTimepicker(element: HTMLElement, compFixture: ComponentFixture) { let dropdownButtonEl = element.querySelector('.sky-dropdown-button') as HTMLElement; dropdownButtonEl.click(); + tick(); compFixture.detectChanges(); + tick(); } function setInput( @@ -58,6 +61,7 @@ describe('Timepicker', () => { ], imports: [ SkyTimepickerModule, + NoopAnimationsModule, FormsModule ] }); @@ -67,9 +71,7 @@ describe('Timepicker', () => { component = fixture.componentInstance; }); - function verifyTimepicker( - element: HTMLElement - ) { + function verifyTimepicker(element: HTMLElement) { fixture.detectChanges(); fixture.whenStable(); let sections = element.querySelectorAll('.sky-timepicker-container'); @@ -98,17 +100,31 @@ describe('Timepicker', () => { } } - it('should have the twelve hour timepicker', () => { + it('should have the twelve hour timepicker', fakeAsync(() => { + fixture.detectChanges(); component.timeFormat = 'hh'; openTimepicker(nativeElement, fixture); verifyTimepicker(nativeElement); - }); + })); - it('should have the twenty four hour timepicker', () => { + it('should have the twenty four hour timepicker', fakeAsync(() => { + fixture.detectChanges(); component.timeFormat = 'HH'; openTimepicker(nativeElement, fixture); verifyTimepicker(nativeElement); - }); + })); + + it('should allow closing the timepicker', fakeAsync(() => { + fixture.detectChanges(); + component.timeFormat = 'hh'; + openTimepicker(nativeElement, fixture); + const closeButton = fixture.nativeElement.querySelector('.sky-timepicker-footer button'); + closeButton.click(); + flush(); + tick(); + const dropdown = fixture.nativeElement.querySelector('.sky-popover-container') as HTMLElement; + expect(dropdown.classList.contains('sky-popover-hidden')).toEqual(false); + })); it('should handle input change with a string with the expected timeFormat', fakeAsync(() => { component.timeFormat = 'hh'; @@ -116,11 +132,10 @@ describe('Timepicker', () => { expect(nativeElement.querySelector('input').value).toBe('2:55 AM'); expect(component.selectedTime.local).toEqual('2:55 AM'); })); + describe('validation', () => { - let ngModel: NgModel; beforeEach(() => { - let inputElement = fixture.debugElement.query(By.css('input')); - ngModel = inputElement.injector.get(NgModel); + fixture.detectChanges(); }); it('should have active css when in twelve hour timeFormat', diff --git a/src/modules/timepicker/timepicker.component.html b/src/modules/timepicker/timepicker.component.html index b522024d7..625e313b4 100644 --- a/src/modules/timepicker/timepicker.component.html +++ b/src/modules/timepicker/timepicker.component.html @@ -1,7 +1,10 @@
    - +
    @@ -32,7 +35,11 @@
    diff --git a/src/modules/timepicker/timepicker.component.ts b/src/modules/timepicker/timepicker.component.ts index 49aad1479..d6890a2d5 100644 --- a/src/modules/timepicker/timepicker.component.ts +++ b/src/modules/timepicker/timepicker.component.ts @@ -5,7 +5,16 @@ import { ChangeDetectionStrategy, OnInit } from '@angular/core'; + +import { Subject } from 'rxjs/Subject'; + +import { + SkyDropdownMessage, + SkyDropdownMessageType +} from '../dropdown'; + import { SkyTimepickerTimeOutput } from './timepicker.interface'; + let moment = require('moment'); @Component({ @@ -15,11 +24,11 @@ let moment = require('moment'); changeDetection: ChangeDetectionStrategy.OnPush }) export class SkyTimepickerComponent implements OnInit { - @Output() public selectedTimeChanged: EventEmitter = - new EventEmitter(); + new EventEmitter(); + public dropdownController = new Subject(); public activeTime: Date; public returnFormat: string; public timeFormat: string = 'hh'; @@ -88,14 +97,7 @@ export class SkyTimepickerComponent implements OnInit { } public get selectedTime() { - let setReturn: string; - let returnTime: SkyTimepickerTimeOutput; - if (typeof this.returnFormat !== 'undefined') { - setReturn = moment(this.activeTime).format(this.returnFormat); - } else { - setReturn = moment(this.activeTime).format(this.localeFormat); - } - returnTime = { + const time: SkyTimepickerTimeOutput = { hour: moment(this.activeTime).hour(), minute: moment(this.activeTime).minute(), meridie: moment(this.activeTime).format('A'), @@ -105,7 +107,8 @@ export class SkyTimepickerComponent implements OnInit { customFormat: (typeof this.returnFormat !== 'undefined') ? this.returnFormat : this.localeFormat }; - return returnTime; + + return time; } public setTime(event: any) { @@ -127,6 +130,12 @@ export class SkyTimepickerComponent implements OnInit { } } + public onButtonClick() { + this.dropdownController.next({ + type: SkyDropdownMessageType.Close + }); + } + private set selectedHour(setHour: number) { let hour: number; let hourOffset: number = 0; diff --git a/src/scss/_mixins.scss b/src/scss/_mixins.scss index 02110961d..37da50380 100644 --- a/src/scss/_mixins.scss +++ b/src/scss/_mixins.scss @@ -155,7 +155,7 @@ @mixin sky-icon-button-borderless { color: $sky-text-color-icon-borderless; cursor: pointer; - + &:hover { color: darken($sky-text-color-icon-borderless, 20%); transition: color $sky-transition-time-short; @@ -204,11 +204,19 @@ text-align: left; transition: background-color $sky-transition-time-short; + &.sky-dropdown-item-active, &:hover { background-color: $sky-background-color-neutral-light; } - /deep/ > button { + &.sky-dropdown-item-disabled { + cursor: default; + &:hover { + background-color: transparent; + } + } + + ::ng-deep > button { background-color: transparent; border: none; color: $sky-text-color-default; @@ -217,6 +225,14 @@ padding: 3px $sky-padding-double; text-align: left; width: 100%; + + &[disabled] { + color: $sky-text-color-deemphasized; + + &:hover { + cursor: default; + } + } } } @@ -267,4 +283,4 @@ font-family: FontAwesome; margin-right: 5px; color: $sky-highlight-color-danger; -} \ No newline at end of file +} diff --git a/src/scss/_variables.scss b/src/scss/_variables.scss index 72cd532d9..fa6814e4c 100644 --- a/src/scss/_variables.scss +++ b/src/scss/_variables.scss @@ -87,7 +87,7 @@ $sky-avatar-border-width: 2px !default; // end avatar // begin dropdown -$sky-dropdown-z-index: 1000 !default; +$sky-dropdown-z-index: 999 !default; // end dropdown // begin fluid grid