diff --git a/projects/coreui-angular/src/lib/tabs-2/tab-panel/tab-panel.component.spec.ts b/projects/coreui-angular/src/lib/tabs-2/tab-panel/tab-panel.component.spec.ts index 8d234fde..fe2b3f4e 100644 --- a/projects/coreui-angular/src/lib/tabs-2/tab-panel/tab-panel.component.spec.ts +++ b/projects/coreui-angular/src/lib/tabs-2/tab-panel/tab-panel.component.spec.ts @@ -113,3 +113,85 @@ describe('TabPanelComponent show state', () => { expect(panels[0].classList.contains('show')).toBe(true); }); }); + +@Component({ + template: ` + + + + + + + + A + B + C + + + `, + imports: [TabPanelComponent, TabsComponent, TabDirective, TabsContentComponent, TabsListComponent] +}) +class DisabledTabTestComponent { + readonly active = signal('a'); + readonly disabledKeys = signal([]); +} + +describe('TabPanelComponent with a disabled tab', () => { + let fixture: ComponentFixture; + let tabs: HTMLButtonElement[]; + let panels: HTMLElement[]; + + const render = async () => { + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve)); + await fixture.whenStable(); + tabs = Array.from(fixture.nativeElement.querySelectorAll('button[cTab]')); + panels = Array.from(fixture.nativeElement.querySelectorAll('c-tab-panel')); + }; + + beforeEach(() => { + fixture = TestBed.createComponent(DisabledTabTestComponent); + }); + + it('should keep the selected panel visible when another tab is disabled', async () => { + await render(); + tabs[2].focus(); + await fixture.whenStable(); + fixture.componentInstance.disabledKeys.set(['a']); + await fixture.whenStable(); + expect(panels[2].classList.contains('active')).toBe(true); + expect(panels.some((panel) => panel.classList.contains('invisible'))).toBe(false); + }); + + it('should keep a disabled selected tab selected with its panel visible', async () => { + fixture.componentInstance.active.set('c'); + await render(); + fixture.componentInstance.disabledKeys.set(['c']); + await fixture.whenStable(); + expect(tabs[2].getAttribute('aria-selected')).toBe('true'); + expect(tabs[2].classList.contains('active')).toBe(true); + expect(panels[2].classList.contains('active')).toBe(true); + expect(panels[2].getAttribute('aria-labelledby')).toBe(tabs[2].id); + expect(panels.some((panel) => panel.classList.contains('invisible'))).toBe(false); + }); + + it('should select the first tab when every tab is disabled and no key is set', async () => { + fixture.componentInstance.active.set(undefined); + fixture.componentInstance.disabledKeys.set(['a', 'b', 'c']); + await render(); + expect(fixture.componentInstance.active()).toBe('a'); + expect(tabs[0].getAttribute('aria-selected')).toBe('true'); + expect(panels[0].classList.contains('active')).toBe(true); + }); + + it('should keep the first tab selected and reachable once the tabs are enabled', async () => { + fixture.componentInstance.active.set(undefined); + fixture.componentInstance.disabledKeys.set(['a', 'b', 'c']); + await render(); + fixture.componentInstance.disabledKeys.set([]); + await render(); + expect(fixture.componentInstance.active()).toBe('a'); + expect(tabs[0].disabled).toBe(false); + expect(tabs[0].getAttribute('tabindex')).toBe('0'); + }); +}); diff --git a/projects/coreui-angular/src/lib/tabs-2/tab-panel/tab-panel.component.ts b/projects/coreui-angular/src/lib/tabs-2/tab-panel/tab-panel.component.ts index 865a712e..7877516a 100644 --- a/projects/coreui-angular/src/lib/tabs-2/tab-panel/tab-panel.component.ts +++ b/projects/coreui-angular/src/lib/tabs-2/tab-panel/tab-panel.component.ts @@ -74,7 +74,7 @@ export class TabPanelComponent { protected readonly show = signal(false); readonly visible = computed(() => { - const visible = this.#tabsService.activeItemKey() === this.itemKey() && !this.#tabsService.activeItem()?.disabled; + const visible = this.#tabsService.activeItemKey() === this.itemKey(); this.visibleChange?.emit({ itemKey: this.itemKey(), visible }); return visible; }); @@ -109,8 +109,7 @@ export class TabPanelComponent { 'tab-pane': true, active: this.visible(), fade: this.transition(), - show: this.show(), - invisible: this.#tabsService.activeItem()?.disabled + show: this.show() }) as Record ); diff --git a/projects/coreui-angular/src/lib/tabs-2/tab/tab.directive.spec.ts b/projects/coreui-angular/src/lib/tabs-2/tab/tab.directive.spec.ts index 04217395..e46bbc32 100644 --- a/projects/coreui-angular/src/lib/tabs-2/tab/tab.directive.spec.ts +++ b/projects/coreui-angular/src/lib/tabs-2/tab/tab.directive.spec.ts @@ -67,13 +67,13 @@ describe('TabDirective', () => { expect(document.activeElement).toBe(tabs[2]); }); - it('should deselect and disable the active tab disabled through the setter', async () => { + it('should keep the active tab selected when disabled through the setter', async () => { const directive = fixture.debugElement.queryAll(By.directive(TabDirective))[1].injector.get(TabDirective); directive.disabled = true; await fixture.whenStable(); expect(directive.disabled).toBe(true); expect(tabs[1].hasAttribute('disabled')).toBe(true); - expect(tabs[1].getAttribute('aria-selected')).toBe('false'); + expect(tabs[1].getAttribute('aria-selected')).toBe('true'); }); }); }); diff --git a/projects/coreui-angular/src/lib/tabs-2/tab/tab.directive.ts b/projects/coreui-angular/src/lib/tabs-2/tab/tab.directive.ts index e2a1a6df..b7b9300a 100644 --- a/projects/coreui-angular/src/lib/tabs-2/tab/tab.directive.ts +++ b/projects/coreui-angular/src/lib/tabs-2/tab/tab.directive.ts @@ -124,7 +124,7 @@ export class TabDirective implements FocusableOption, OnInit { ngOnInit(): void { runInInjectionContext(this.#injector, () => { effect(() => { - const isActive = !this.#disabled() && this.#tabsService.activeItemKey() === this.itemKey(); + const isActive = this.#tabsService.activeItemKey() === this.itemKey(); this.isActive.set(isActive); }); }); diff --git a/projects/coreui-angular/src/lib/tabs-2/tabs-list/tabs-list.component.spec.ts b/projects/coreui-angular/src/lib/tabs-2/tabs-list/tabs-list.component.spec.ts index 79aaf0b5..a027dabd 100644 --- a/projects/coreui-angular/src/lib/tabs-2/tabs-list/tabs-list.component.spec.ts +++ b/projects/coreui-angular/src/lib/tabs-2/tabs-list/tabs-list.component.spec.ts @@ -1,3 +1,4 @@ +import { FocusKeyManager } from '@angular/cdk/a11y'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Component, ComponentRef, signal } from '@angular/core'; @@ -54,7 +55,10 @@ describe('TabsListComponent', () => { - + + @if (extraTab()) { + + } @@ -65,18 +69,19 @@ class KeyboardTestComponent { readonly activeItemKey = signal('a'); readonly dir = signal<'ltr' | 'rtl'>('ltr'); readonly disabledKey = signal(undefined); + readonly extraTab = signal(false); } describe('TabsListComponent keyboard', () => { let fixture: ComponentFixture; let tabs: HTMLButtonElement[]; - const keydown = async (key: string) => { - const keyCode = { ArrowDown: 40, ArrowLeft: 37, ArrowRight: 39 }[key]; - document.activeElement?.dispatchEvent( - new KeyboardEvent('keydown', { key, keyCode, bubbles: true, cancelable: true }) - ); + const keydown = async (key: string, target: Element | null = document.activeElement) => { + const keyCode = { ArrowDown: 40, ArrowLeft: 37, ArrowRight: 39, End: 35, Home: 36 }[key]; + const event = new KeyboardEvent('keydown', { key, keyCode, bubbles: true, cancelable: true }); + target?.dispatchEvent(event); await fixture.whenStable(); + return event; }; const render = async () => { @@ -90,6 +95,10 @@ describe('TabsListComponent keyboard', () => { fixture = TestBed.createComponent(KeyboardTestComponent); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should focus and select the next tab on ArrowRight', async () => { await render(); tabs[0].focus(); @@ -102,6 +111,16 @@ describe('TabsListComponent keyboard', () => { expect(tabs[0].getAttribute('tabindex')).toBe('-1'); }); + it('should move from a tab selected by a click', async () => { + await render(); + tabs[2].click(); + await fixture.whenStable(); + expect(fixture.componentInstance.activeItemKey()).toBe('c'); + await keydown('ArrowLeft', tabs[2]); + expect(document.activeElement).toBe(tabs[1]); + expect(fixture.componentInstance.activeItemKey()).toBe('b'); + }); + it('should skip a disabled tab', async () => { fixture.componentInstance.disabledKey.set('b'); await render(); @@ -111,6 +130,66 @@ describe('TabsListComponent keyboard', () => { expect(fixture.componentInstance.activeItemKey()).toBe('c'); }); + it('should focus and select the last tab on End and the first on Home', async () => { + await render(); + tabs[0].focus(); + const end = await keydown('End'); + expect(end.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(tabs[2]); + expect(fixture.componentInstance.activeItemKey()).toBe('c'); + expect(tabs[2].getAttribute('aria-selected')).toBe('true'); + const home = await keydown('Home'); + expect(home.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(tabs[0]); + expect(fixture.componentInstance.activeItemKey()).toBe('a'); + }); + + it('should move from a tab selected from outside', async () => { + await render(); + fixture.componentInstance.activeItemKey.set('c'); + await fixture.whenStable(); + expect(tabs[2].getAttribute('aria-selected')).toBe('true'); + await keydown('ArrowLeft', tabs[2]); + expect(document.activeElement).toBe(tabs[1]); + expect(fixture.componentInstance.activeItemKey()).toBe('b'); + }); + + it('should ignore keys that do not come from a tab', async () => { + await render(); + tabs[0].focus(); + const list = fixture.nativeElement.querySelector('c-tabs-list'); + for (const [key, keyCode] of [ + ['ArrowRight', 39], + ['End', 35] + ] as const) { + const event = new KeyboardEvent('keydown', { key, keyCode, bubbles: true, cancelable: true }); + list.dispatchEvent(event); + await fixture.whenStable(); + expect(event.defaultPrevented).toBe(false); + expect(document.activeElement).toBe(tabs[0]); + expect(fixture.componentInstance.activeItemKey()).toBe('a'); + } + }); + + it('should skip a disabled tab on Home', async () => { + fixture.componentInstance.disabledKey.set('a'); + fixture.componentInstance.activeItemKey.set('c'); + await render(); + tabs[2].focus(); + await keydown('Home'); + expect(document.activeElement).toBe(tabs[1]); + expect(fixture.componentInstance.activeItemKey()).toBe('b'); + }); + + it('should skip a disabled tab on End', async () => { + fixture.componentInstance.disabledKey.set('c'); + await render(); + tabs[0].focus(); + await keydown('End'); + expect(document.activeElement).toBe(tabs[1]); + expect(fixture.componentInstance.activeItemKey()).toBe('b'); + }); + it('should wrap from the last tab to the first and back', async () => { fixture.componentInstance.activeItemKey.set('c'); await render(); @@ -132,6 +211,14 @@ describe('TabsListComponent keyboard', () => { expect(document.activeElement).toBe(tabs[0]); }); + it('should mirror the arrows when the direction changes without a render', async () => { + await render(); + tabs[0].focus(); + fixture.nativeElement.querySelector('div').dir = 'rtl'; + await keydown('ArrowLeft'); + expect(document.activeElement).toBe(tabs[1]); + }); + it('should ignore the vertical arrows', async () => { await render(); tabs[0].focus(); @@ -147,4 +234,43 @@ describe('TabsListComponent keyboard', () => { expect(fixture.componentInstance.activeItemKey()).toBe('b'); expect(tabs[1].getAttribute('aria-selected')).toBe('true'); }); + + it('should reach a tab added after the first render', async () => { + await render(); + fixture.componentInstance.extraTab.set(true); + await render(); + tabs[0].focus(); + await keydown('ArrowRight'); + await keydown('ArrowRight'); + await keydown('ArrowRight'); + expect(document.activeElement).toBe(tabs[3]); + expect(fixture.componentInstance.activeItemKey()).toBe('d'); + }); + + it('should keep the key on a disabled selected tab when the tab list changes', async () => { + fixture.componentInstance.activeItemKey.set('c'); + fixture.componentInstance.disabledKey.set('c'); + await render(); + fixture.componentInstance.extraTab.set(true); + await render(); + expect(fixture.componentInstance.activeItemKey()).toBe('c'); + expect(tabs[2].getAttribute('aria-selected')).toBe('true'); + }); + + it('should keep one key manager and destroy it with the list', async () => { + fixture.destroy(); + const created = vi.spyOn(FocusKeyManager.prototype, 'withWrap'); + const destroyed = vi.spyOn(FocusKeyManager.prototype, 'destroy'); + fixture = TestBed.createComponent(KeyboardTestComponent); + await render(); + for (const extraTab of [true, false]) { + fixture.componentInstance.extraTab.set(extraTab); + await render(); + } + fixture.componentInstance.dir.set('rtl'); + await render(); + fixture.destroy(); + expect(created).toHaveBeenCalledTimes(1); + expect(destroyed).toHaveBeenCalledTimes(1); + }); }); diff --git a/projects/coreui-angular/src/lib/tabs-2/tabs-list/tabs-list.component.ts b/projects/coreui-angular/src/lib/tabs-2/tabs-list/tabs-list.component.ts index 34d9d08e..10b5e067 100644 --- a/projects/coreui-angular/src/lib/tabs-2/tabs-list/tabs-list.component.ts +++ b/projects/coreui-angular/src/lib/tabs-2/tabs-list/tabs-list.component.ts @@ -1,6 +1,5 @@ import { FocusKeyManager } from '@angular/cdk/a11y'; import { - afterEveryRender, Component, computed, contentChildren, @@ -8,13 +7,11 @@ import { effect, ElementRef, inject, + Injector, input, InputSignal, - signal, untracked } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { tap } from 'rxjs/operators'; import { TabDirective } from '../tab/tab.directive'; import { TabsService } from '../tabs.service'; import { RtlService } from '../../services'; @@ -32,16 +29,16 @@ import { RtlService } from '../../services'; export class TabsListComponent { readonly #destroyRef = inject(DestroyRef); readonly #elementRef = inject(ElementRef); + readonly #injector = inject(Injector); readonly tabsService = inject(TabsService); readonly #rtlService = inject(RtlService); - readonly #isRtl = signal(false); constructor() { - afterEveryRender({ - read: () => { - this.#isRtl.set(this.#rtlService.isRTL(this.#elementRef.nativeElement)); - } + this.#focusKeyManager.change.subscribe(() => { + this.tabsService.activeItemKey.set(this.#focusKeyManager.activeItem?.itemKey()); + this.tabsService.activeItem.set(this.#focusKeyManager.activeItem); }); + this.#destroyRef.onDestroy(() => this.#focusKeyManager.destroy()); } /** @@ -79,7 +76,11 @@ export class TabsListComponent { }); readonly tabs = contentChildren(TabDirective); - #focusKeyManager!: FocusKeyManager; + protected readonly tabElements = contentChildren(TabDirective, { read: ElementRef }); + readonly #focusKeyManager = new FocusKeyManager(this.tabs, this.#injector) + .skipPredicate((tab) => tab.disabled === true) + .withHomeAndEnd() + .withWrap(); readonly #tabsEffect = effect(() => { const tabs = this.tabs(); @@ -87,49 +88,31 @@ export class TabsListComponent { return; } - const isRtl = this.#isRtl(); - - this.#focusKeyManager = new FocusKeyManager(tabs) - .skipPredicate((tab) => tab.disabled === true) - .withHorizontalOrientation(isRtl ? 'rtl' : 'ltr') - .withHomeAndEnd() - .withWrap(); - - this.#focusKeyManager.change - .pipe( - tap((value) => { - this.tabsService.activeItemKey.set(this.#focusKeyManager.activeItem?.itemKey()); - this.tabsService.activeItem.set(this.#focusKeyManager.activeItem); - }), - takeUntilDestroyed(this.#destroyRef) - ) - .subscribe(); - untracked(() => { setTimeout(() => { - const activeItem = tabs.find((tab) => tab.isActive()) ?? tabs.find((tab) => !tab.disabled); - const activeItemIndex = tabs.findIndex((tab) => tab === activeItem); - this.#focusKeyManager?.updateActiveItem(activeItemIndex < 0 ? 0 : activeItemIndex); - this.tabsService.activeItemKey.set(this.#focusKeyManager.activeItem?.itemKey()); - this.tabsService.activeItem.set(this.#focusKeyManager.activeItem); + const activeItem = tabs.find((tab) => tab.isActive()) ?? tabs.find((tab) => !tab.disabled) ?? tabs[0]; + this.#focusKeyManager.updateActiveItem(activeItem); + this.tabsService.activeItemKey.set(activeItem.itemKey()); + this.tabsService.activeItem.set(activeItem); }); }); }); - readonly #tabsServiceEffect = effect(() => { - const activeItemIndex = this.tabs().findIndex( - (tab) => untracked(tab.isActive) && untracked(tab.itemKey) === this.tabsService.activeItemKey() - ); - this.#focusKeyManager?.updateActiveItem(activeItemIndex < 0 ? 0 : activeItemIndex); - }); - onKeyDown($event: any) { - if (['ArrowLeft', 'ArrowRight'].includes($event.key)) { + if (['ArrowLeft', 'ArrowRight', 'End', 'Home'].includes($event.key)) { + const index = this.tabElements().findIndex((tab) => tab.nativeElement.contains($event.target)); + if (index < 0) { + return; + } + this.#focusKeyManager.withHorizontalOrientation( + this.#rtlService.isRTL(this.#elementRef.nativeElement) ? 'rtl' : 'ltr' + ); + this.#focusKeyManager.updateActiveItem(index); this.#focusKeyManager.onKeydown($event); return; } if (['Tab'].includes($event.key)) { - this.#focusKeyManager?.tabOut.next(); + this.#focusKeyManager.tabOut.next(); } return; }