diff --git a/src/modules/sectioned-form/fixtures/sectioned-form.component.fixture.html b/src/modules/sectioned-form/fixtures/sectioned-form.component.fixture.html index b9c1de5d0..454148e4a 100644 --- a/src/modules/sectioned-form/fixtures/sectioned-form.component.fixture.html +++ b/src/modules/sectioned-form/fixtures/sectioned-form.component.fixture.html @@ -6,4 +6,4 @@ - \ No newline at end of file + diff --git a/src/modules/sectioned-form/sectioned-form-section.component.html b/src/modules/sectioned-form/sectioned-form-section.component.html index e45361c05..099a3f6c4 100644 --- a/src/modules/sectioned-form/sectioned-form-section.component.html +++ b/src/modules/sectioned-form/sectioned-form-section.component.html @@ -1,16 +1,21 @@ -
+ [tabHeaderCount]="itemCount" + [tabHeading]="heading" + [tabId]="sectionTabId"> +
diff --git a/src/modules/sectioned-form/sectioned-form-section.component.ts b/src/modules/sectioned-form/sectioned-form-section.component.ts index c5340677b..8bbe7d2c6 100644 --- a/src/modules/sectioned-form/sectioned-form-section.component.ts +++ b/src/modules/sectioned-form/sectioned-form-section.component.ts @@ -3,14 +3,26 @@ import { Input, ViewChild, OnInit, - OnDestroy + OnDestroy, + ChangeDetectorRef } from '@angular/core'; -import { Subject } from 'rxjs/Subject'; +import { + Subject +} from 'rxjs/Subject'; import 'rxjs/add/operator/takeUntil'; -import { SkyVerticalTabComponent } from './../vertical-tabset/vertical-tab.component'; -import { SkySectionedFormService } from './sectioned-form.service'; +import { + SkyVerticalTabComponent +} from './../vertical-tabset/vertical-tab.component'; +import { + SkySectionedFormService +} from './sectioned-form.service'; +import { + SkyVerticalTabsetService +} from '../vertical-tabset/vertical-tabset.service'; + +let nextId = 0; @Component({ selector: 'sky-sectioned-form-section', @@ -19,6 +31,8 @@ import { SkySectionedFormService } from './sectioned-form.service'; styleUrls: ['./sectioned-form-section.component.scss'] }) export class SkySectionedFormSectionComponent implements OnInit, OnDestroy { + public sectionTabId = `sky-sectioned-form-tab-${++nextId}`; + public sectionContentId = `sky-sectioned-form-section-${++nextId}`; @Input() public heading: string; @@ -29,24 +43,46 @@ export class SkySectionedFormSectionComponent implements OnInit, OnDestroy { @Input() public active: boolean; + public get ariaRole(): string { + return this.isMobile ? undefined : 'tabpanel'; + } + + public get ariaLabelledby() { + return this.isMobile ? undefined : this.sectionTabId; + } + public fieldRequired: boolean; public fieldInvalid: boolean; @ViewChild(SkyVerticalTabComponent) public tab: SkyVerticalTabComponent; + private isMobile = false; private _ngUnsubscribe = new Subject(); - constructor(private sectionedFormService: SkySectionedFormService) {} + constructor( + private sectionedFormService: SkySectionedFormService, + private tabsetService: SkyVerticalTabsetService, + private changeRef: ChangeDetectorRef + ) {} public ngOnInit() { + this.isMobile = this.tabsetService.isMobile(); + this.changeRef.detectChanges(); + + this.tabsetService.switchingMobile + .subscribe((mobile: boolean) => { + this.isMobile = mobile; + this.changeRef.detectChanges(); + }); + this.sectionedFormService.requiredChange - .takeUntil(this._ngUnsubscribe) - .subscribe((required: boolean) => this.fieldRequired = required); + .takeUntil(this._ngUnsubscribe) + .subscribe((required: boolean) => this.fieldRequired = required); - this.sectionedFormService.invalidChange - .takeUntil(this._ngUnsubscribe) - .subscribe((invalid: boolean) => this.fieldInvalid = invalid); + this.sectionedFormService.invalidChange + .takeUntil(this._ngUnsubscribe) + .subscribe((invalid: boolean) => this.fieldInvalid = invalid); } public ngOnDestroy() { diff --git a/src/modules/sectioned-form/sectioned-form.component.html b/src/modules/sectioned-form/sectioned-form.component.html index 4a3220477..88a74fc83 100644 --- a/src/modules/sectioned-form/sectioned-form.component.html +++ b/src/modules/sectioned-form/sectioned-form.component.html @@ -1,16 +1,19 @@ -
-
+
-
diff --git a/src/modules/sectioned-form/sectioned-form.component.scss b/src/modules/sectioned-form/sectioned-form.component.scss index 299eabf0c..e814e82b0 100644 --- a/src/modules/sectioned-form/sectioned-form.component.scss +++ b/src/modules/sectioned-form/sectioned-form.component.scss @@ -15,4 +15,3 @@ flex-basis: 70%; } } - diff --git a/src/modules/sectioned-form/sectioned-form.component.spec.ts b/src/modules/sectioned-form/sectioned-form.component.spec.ts index 6fe0c6b27..b3961bba6 100644 --- a/src/modules/sectioned-form/sectioned-form.component.spec.ts +++ b/src/modules/sectioned-form/sectioned-form.component.spec.ts @@ -1,21 +1,35 @@ -import { SkySectionedFormComponent } from './sectioned-form.component'; -import { TestBed } from '@angular/core/testing'; +import { + TestBed +} from '@angular/core/testing'; + import { expect } from '@blackbaud/skyux-builder/runtime/testing/browser'; -import { SkySectionedFormFixturesModule } from './fixtures/sectioned-form-fixtures.module'; -import { SkySectionedFormFixtureComponent } from './fixtures/sectioned-form.component.fixture'; +import { + SkyMediaQueryService, + SkyMediaBreakpoints +} from '../media-queries'; +import { + SkySectionedFormComponent +} from './sectioned-form.component'; + +import { + SkySectionedFormFixturesModule +} from './fixtures/sectioned-form-fixtures.module'; +import { + SkySectionedFormFixtureComponent +} from './fixtures/sectioned-form.component.fixture'; import { SkySectionedFormNoSectionsFixtureComponent } from './fixtures/sectioned-form-no-sections.component.fixture'; - import { SkySectionedFormNoActiveFixtureComponent } from './fixtures/sectioned-form-no-active.component.fixture'; -import { MockSkyMediaQueryService } from './../testing/mocks/mock-media-query.service'; -import { SkyMediaQueryService, SkyMediaBreakpoints } from '../media-queries'; +import { + MockSkyMediaQueryService +} from './../testing/mocks/mock-media-query.service'; function getVisibleContent(el: any) { return el.querySelectorAll('.sky-vertical-tab-content-pane:not(.sky-vertical-tab-hidden)'); @@ -118,7 +132,7 @@ describe('Sectioned form component', () => { let activeTab = tabs[1]; expect(activeTab.classList.contains('sky-tab-field-required')).toBe(false); - expect(activeTab.getAttribute('aria-required')).toBeFalsy(); + expect(activeTab.querySelector('a').getAttribute('aria-required')).toBeFalsy(); // mark required let checkbox = el.querySelector('#requiredTestCheckbox input'); @@ -129,7 +143,7 @@ describe('Sectioned form component', () => { tabs = el.querySelectorAll('sky-vertical-tab'); let requiredTab = tabs[0]; expect(requiredTab.classList.contains('sky-tab-field-required')).toBe(true); - expect(requiredTab.getAttribute('aria-required')).toBe('true'); + expect(requiredTab.querySelector('a').getAttribute('aria-required')).toBe('true'); }); it('section should respect required field change after switching tabs', () => { @@ -213,6 +227,28 @@ describe('Sectioned form component', () => { expect(tabs.length).toBe(2); }); + it('should not use tab aria-associations and roles in mobile view', () => { + mockQueryService.current = SkyMediaBreakpoints.xs; + let fixture = createTestComponent(); + let el = fixture.nativeElement; + fixture.detectChanges(); + + let content = getVisibleContent(el); + for (let pane of content) { + expect(pane.getAttribute('aria-labelledby')).toBeFalsy(); + expect(pane.getAttribute('role')).toBeFalsy(); + } + + fixture.componentInstance.sectionedForm.showTabs(); + fixture.detectChanges(); + + let tabs = el.querySelectorAll('.sky-vertical-tab'); + for (let tab of tabs) { + expect(tab.getAttribute('aria-controls')).toBeFalsy(); + expect(tab.getAttribute('role')).toBeFalsy(); + } + }); + it('section should respect invalid field change', () => { let fixture = createTestComponent(); let el = fixture.nativeElement; @@ -225,7 +261,7 @@ describe('Sectioned form component', () => { let activeTab = tabs[1]; expect(activeTab.classList.contains('sky-tab-field-invalid')).toBe(false); - expect(activeTab.getAttribute('aria-invalid')).toBeFalsy(); + expect(activeTab.querySelector('a').getAttribute('aria-invalid')).toBeFalsy(); // mark invalid let checkbox = el.querySelector('#invalidTestCheckbox input'); @@ -236,7 +272,30 @@ describe('Sectioned form component', () => { tabs = el.querySelectorAll('sky-vertical-tab'); let invalidTab = tabs[0]; expect(invalidTab.classList.contains('sky-tab-field-invalid')).toBe(true); - expect(invalidTab.getAttribute('aria-invalid')).toBe('true'); + expect(invalidTab.querySelector('a').getAttribute('aria-invalid')).toBe('true'); + }); + + it('section should have appropriate aria labels', () => { + let fixture = createTestComponent(); + let el = fixture.nativeElement; + + fixture.detectChanges(); + + // check section is not invalid + let tabs = el.querySelectorAll('sky-vertical-tab a'); + expect(tabs.length).toBe(2); + + let inactiveTab = tabs[0]; + let inactiveTabContent = el.querySelector('#' + inactiveTab.getAttribute('aria-controls')); + expect(inactiveTab.getAttribute('aria-selected')).toBeFalsy(); + expect(inactiveTab.getAttribute('aria-controls')).toBe(inactiveTabContent.id); + expect(inactiveTabContent.getAttribute('aria-labelledby')).toBe(inactiveTab.id); + + let activeTab = tabs[1]; + let activeTabContent = el.querySelector('#' + activeTab.getAttribute('aria-controls')); + expect(activeTab.getAttribute('aria-selected')).toBe('true'); + expect(activeTab.getAttribute('aria-controls')).toBe(activeTabContent.id); + expect(activeTabContent.getAttribute('aria-labelledby')).toBe(activeTab.id); }); it('should show content after resizing screen', () => { @@ -252,6 +311,7 @@ describe('Sectioned form component', () => { // resize screen out of mobile mockQueryService.current = SkyMediaBreakpoints.lg; + fixture.detectChanges(); fixture.componentInstance.sectionedForm.tabService.updateContent(); fixture.detectChanges(); @@ -262,6 +322,7 @@ describe('Sectioned form component', () => { // resize back to mobile mockQueryService.current = SkyMediaBreakpoints.xs; + fixture.detectChanges(); fixture.componentInstance.sectionedForm.tabService.updateContent(); fixture.detectChanges(); @@ -271,6 +332,7 @@ describe('Sectioned form component', () => { // resize to widescreen mockQueryService.current = SkyMediaBreakpoints.lg; + fixture.detectChanges(); fixture.componentInstance.sectionedForm.tabService.updateContent(); fixture.detectChanges(); diff --git a/src/modules/sectioned-form/sectioned-form.component.ts b/src/modules/sectioned-form/sectioned-form.component.ts index 2a9a221e1..1eeb51bb2 100644 --- a/src/modules/sectioned-form/sectioned-form.component.ts +++ b/src/modules/sectioned-form/sectioned-form.component.ts @@ -58,9 +58,14 @@ export class SkySectionedFormComponent implements OnInit, OnDestroy, AfterViewCh @Output() public indexChanged: EventEmitter = new EventEmitter(); + public get ariaRole(): string { + return this.isMobile ? undefined : 'tablist'; + } + @ViewChild('skySectionSideContent') public content: ElementRef; + private isMobile = false; private _ngUnsubscribe = new Subject(); constructor( @@ -77,10 +82,15 @@ export class SkySectionedFormComponent implements OnInit, OnDestroy, AfterViewCh this.tabService.switchingMobile .takeUntil(this._ngUnsubscribe) - .subscribe((mobile: boolean) => this.changeRef.detectChanges()); + .subscribe((mobile: boolean) => { + this.isMobile = mobile; + this.changeRef.detectChanges(); + }); if (this.tabService.isMobile()) { + this.isMobile = true; this.tabService.animationVisibleState = VISIBLE_STATE; + this.changeRef.detectChanges(); } } diff --git a/src/modules/vertical-tabset/fixtures/vertical-tabset.component.fixture.html b/src/modules/vertical-tabset/fixtures/vertical-tabset.component.fixture.html index 7e2e3fa7e..e6325b182 100644 --- a/src/modules/vertical-tabset/fixtures/vertical-tabset.component.fixture.html +++ b/src/modules/vertical-tabset/fixtures/vertical-tabset.component.fixture.html @@ -1,10 +1,19 @@ - - + - + Group 1 Tab 1 content @@ -12,7 +21,7 @@ - - - \ No newline at end of file + +
diff --git a/src/modules/vertical-tabset/vertical-tab.component.html b/src/modules/vertical-tabset/vertical-tab.component.html index 243ebe7ef..8bf39cb80 100644 --- a/src/modules/vertical-tabset/vertical-tab.component.html +++ b/src/modules/vertical-tabset/vertical-tab.component.html @@ -1,25 +1,29 @@ - + (click)="activateTab()" + (keyup.enter)="activateTab()">
{{ tabHeading }} - ({{ tabHeaderCount }})
- @@ -28,10 +32,14 @@
-
+ +
+
+ diff --git a/src/modules/vertical-tabset/vertical-tab.component.ts b/src/modules/vertical-tabset/vertical-tab.component.ts index 3ee814a04..1d877452c 100644 --- a/src/modules/vertical-tabset/vertical-tab.component.ts +++ b/src/modules/vertical-tabset/vertical-tab.component.ts @@ -21,6 +21,9 @@ import { SkyVerticalTabsetService } from './vertical-tabset.service'; }) export class SkyVerticalTabComponent implements OnInit, OnDestroy { + @Input() + public tabId: string; + @Input() public active: boolean = false; @@ -33,6 +36,31 @@ export class SkyVerticalTabComponent implements OnInit, OnDestroy { @Input() public disabled: boolean = false; + @Input() + public get ariaControls(): string { + return this.isMobile ? undefined : this._ariaControls; + } + public set ariaControls(value: string) { + this._ariaControls = value; + } + + @Input() + public get ariaRole(): string { + if (this.isMobile) { + return undefined; + } + return this._ariaRole || 'tab'; + } + public set ariaRole(value: string) { + this._ariaRole = value; + } + + @Input() + public ariaInvalid: boolean; + + @Input() + public ariaRequired: boolean; + @Input() public get showTabRightArrow() { return this._showTabRightArrow && this.tabsetService.isMobile(); @@ -47,6 +75,9 @@ export class SkyVerticalTabComponent implements OnInit, OnDestroy { @ViewChild('tabContentWrapper') public tabContent: ElementRef; + private isMobile = false; + private _ariaControls: string; + private _ariaRole: string; private _showTabRightArrow: boolean = false; private _mobileSubscription = new Subject(); @@ -55,8 +86,14 @@ export class SkyVerticalTabComponent implements OnInit, OnDestroy { private changeRef: ChangeDetectorRef) {} public ngOnInit() { + this.isMobile = this.tabsetService.isMobile(); + this.changeRef.detectChanges(); + this.tabsetService.switchingMobile - .subscribe((mobile: boolean) => this.changeRef.detectChanges()); + .subscribe((mobile: boolean) => { + this.isMobile = mobile; + this.changeRef.detectChanges(); + }); this.tabsetService.addTab(this); } diff --git a/src/modules/vertical-tabset/vertical-tabset.component.html b/src/modules/vertical-tabset/vertical-tabset.component.html index dbb46804d..225457fc4 100644 --- a/src/modules/vertical-tabset/vertical-tabset.component.html +++ b/src/modules/vertical-tabset/vertical-tabset.component.html @@ -1,19 +1,20 @@
-
-
-
+
-
-
+
diff --git a/src/modules/vertical-tabset/vertical-tabset.component.spec.ts b/src/modules/vertical-tabset/vertical-tabset.component.spec.ts index 09d618a9c..b13520ec4 100644 --- a/src/modules/vertical-tabset/vertical-tabset.component.spec.ts +++ b/src/modules/vertical-tabset/vertical-tabset.component.spec.ts @@ -1,22 +1,34 @@ -import { TestBed } from '@angular/core/testing'; -import { SkyVerticalTabsFixturesModule } from './fixtures/vertical-tabs-fixtures.module'; -import { SkyVerticalTabsetComponent } from '../vertical-tabset/vertical-tabset.component'; -import { VerticalTabsetTestComponent } from './fixtures/vertical-tabset.component.fixture'; +import { + TestBed +} from '@angular/core/testing'; + +import { + SkyVerticalTabsetComponent +} from '../vertical-tabset/vertical-tabset.component'; +import { + SkyMediaQueryService, + SkyMediaBreakpoints +} from '../media-queries'; +import { + SkyVerticalTabsFixturesModule +} from './fixtures/vertical-tabs-fixtures.module'; +import { + VerticalTabsetTestComponent +} from './fixtures/vertical-tabset.component.fixture'; import { VerticalTabsetNoActiveTestComponent } from './fixtures/vertical-tabset-no-active.component.fixture'; - import { VerticalTabsetEmptyGroupTestComponent } from './fixtures/vertical-tabset-empty-group.component'; - import { VerticalTabsetNoGroupTestComponent } from './fixtures/vertical-tabset-no-group.component.fixture'; -import { MockSkyMediaQueryService } from './../testing/mocks/mock-media-query.service'; -import { SkyMediaQueryService, SkyMediaBreakpoints } from '../media-queries'; +import { + MockSkyMediaQueryService +} from '../testing/mocks/mock-media-query.service'; let mockQueryService = new MockSkyMediaQueryService(); @@ -97,6 +109,22 @@ describe('Vertical tabset component', () => { expect(openGroup[0].textContent.trim()).toBe('Group 2'); }); + it('should pass through aria inputs, id, and set role', () => { + mockQueryService.current = SkyMediaBreakpoints.lg; + let fixture = createTestComponent(); + let el = fixture.nativeElement as HTMLElement; + + fixture.detectChanges(); + + // check open tab content + const tab = el.querySelector('sky-vertical-tab a'); + expect(tab.id).toBe('some-tab'); + expect(tab.getAttribute('aria-controls')).toBe('some-div'); + expect(tab.getAttribute('aria-invalid')).toBe('true'); + expect(tab.getAttribute('aria-required')).toBe('true'); + expect(tab.getAttribute('role')).toBe('tab'); + }); + it('check closing of group', () => { mockQueryService.current = SkyMediaBreakpoints.lg; let fixture = createTestComponent(); @@ -227,6 +255,24 @@ describe('Vertical tabset component', () => { expect(visibleTabs[0].textContent.trim()).toBe('Group 1 Tab 2 content'); }); + it('tabs should not have tab aria associations and roles in mobile view', () => { + mockQueryService.current = SkyMediaBreakpoints.xs; + let fixture = createTestComponent(); + let el = fixture.nativeElement; + + fixture.detectChanges(); + + // click show tabs + const showTabsButton = el.querySelector('.sky-vertical-tabset-show-tabs-btn'); + showTabsButton.click(); + + fixture.detectChanges(); + + const visibleTab = el.querySelector('.sky-vertical-tab'); + expect(visibleTab.getAttribute('aria-controls')).toBeFalsy(); + expect(visibleTab.getAttribute('role')).toBeFalsy(); + }); + it('should hide tabs when switching from widescreen to mobile', () => { mockQueryService.current = SkyMediaBreakpoints.lg; let fixture = createTestComponent(); diff --git a/src/modules/vertical-tabset/vertical-tabset.component.ts b/src/modules/vertical-tabset/vertical-tabset.component.ts index f1e3ec628..0d615cfef 100644 --- a/src/modules/vertical-tabset/vertical-tabset.component.ts +++ b/src/modules/vertical-tabset/vertical-tabset.component.ts @@ -61,6 +61,17 @@ export class SkyVerticalTabsetComponent implements OnInit, AfterViewChecked, OnD @Input() public showTabsText: string = this.resources.getString('vertical_tabs_show_tabs_text'); + @Input() + public get ariaRole(): string { + if (this.isMobile) { + return undefined; + } + return this._ariaRole || 'tablist'; + } + public set ariaRole(value: string) { + this._ariaRole = value; + } + @Output() public activeChange = new EventEmitter(); @@ -70,7 +81,9 @@ export class SkyVerticalTabsetComponent implements OnInit, AfterViewChecked, OnD @ViewChild('skySideContent') public content: ElementRef; + private isMobile = false; private _ngUnsubscribe = new Subject(); + private _ariaRole: string; constructor( public tabService: SkyVerticalTabsetService, @@ -87,10 +100,15 @@ export class SkyVerticalTabsetComponent implements OnInit, AfterViewChecked, OnD this.tabService.switchingMobile .takeUntil(this._ngUnsubscribe) - .subscribe((mobile: boolean) => this.changeRef.detectChanges()); + .subscribe((mobile: boolean) => { + this.isMobile = mobile; + this.changeRef.detectChanges(); + }); if (this.tabService.isMobile()) { + this.isMobile = true; this.tabService.animationVisibleState = VISIBLE_STATE; + this.changeRef.detectChanges(); } }