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;
}