Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,85 @@ describe('TabPanelComponent show state', () => {
expect(panels[0].classList.contains('show')).toBe(true);
});
});

@Component({
template: `
<c-tabs [(activeItemKey)]="active">
<c-tabs-list>
<button cTab itemKey="a" [disabled]="disabledKeys().includes('a')">A</button>
<button cTab itemKey="b" [disabled]="disabledKeys().includes('b')">B</button>
<button cTab itemKey="c" [disabled]="disabledKeys().includes('c')">C</button>
</c-tabs-list>
<c-tabs-content>
<c-tab-panel itemKey="a" [transition]="false">A</c-tab-panel>
<c-tab-panel itemKey="b" [transition]="false">B</c-tab-panel>
<c-tab-panel itemKey="c" [transition]="false">C</c-tab-panel>
</c-tabs-content>
</c-tabs>
`,
imports: [TabPanelComponent, TabsComponent, TabDirective, TabsContentComponent, TabsListComponent]
})
class DisabledTabTestComponent {
readonly active = signal<string | number | undefined>('a');
readonly disabledKeys = signal<string[]>([]);
}

describe('TabPanelComponent with a disabled tab', () => {
let fixture: ComponentFixture<DisabledTabTestComponent>;
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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
Expand Down Expand Up @@ -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<string, boolean>
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { FocusKeyManager } from '@angular/cdk/a11y';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, ComponentRef, signal } from '@angular/core';

Expand Down Expand Up @@ -54,7 +55,10 @@ describe('TabsListComponent', () => {
<c-tabs-list>
<button cTab itemKey="a" [disabled]="disabledKey() === 'a'">A</button>
<button cTab itemKey="b" [disabled]="disabledKey() === 'b'">B</button>
<button cTab itemKey="c">C</button>
<button cTab itemKey="c" [disabled]="disabledKey() === 'c'">C</button>
@if (extraTab()) {
<button cTab itemKey="d">D</button>
}
</c-tabs-list>
</c-tabs>
</div>
Expand All @@ -65,18 +69,19 @@ class KeyboardTestComponent {
readonly activeItemKey = signal<string | number | undefined>('a');
readonly dir = signal<'ltr' | 'rtl'>('ltr');
readonly disabledKey = signal<string | undefined>(undefined);
readonly extraTab = signal(false);
}

describe('TabsListComponent keyboard', () => {
let fixture: ComponentFixture<KeyboardTestComponent>;
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 () => {
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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);
});
});
Loading
Loading