Skip to content

Commit eac0cf3

Browse files
committedJun 15, 2024
feat(tabs): Angular tabs reimagined structure, keyboard interactions and WAI-ARIA support
1 parent 4fb1b1e commit eac0cf3

17 files changed

+595
-0
lines changed
 
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './public_api';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export { Tabs2Module } from './tabs2.module';
2+
export { TabsComponent } from './tabs.component';
3+
export { TabsContentComponent } from './tabs-content/tabs-content.component';
4+
export { TabsListComponent } from './tabs-list/tabs-list.component';
5+
export { TabsService } from './tabs.service';
6+
export { TabDirective } from './tab/tab.directive';
7+
export { TabPanelComponent } from './tab-panel/tab-panel.component';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Component } from '@angular/core';
2+
import { ComponentFixture, TestBed } from '@angular/core/testing';
3+
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
4+
import { TabsComponent } from '../tabs.component';
5+
import { TabDirective } from '../tab/tab.directive';
6+
import { TabsContentComponent } from '../tabs-content/tabs-content.component';
7+
import { TabsListComponent } from '../tabs-list/tabs-list.component';
8+
import { TabPanelComponent } from './tab-panel.component';
9+
10+
@Component({
11+
template: `
12+
<c-tabs activeItemKey="test-0">
13+
<c-tabs-list>
14+
<button cTab itemKey="test-0"></button>
15+
<button cTab itemKey="test-1"></button>
16+
</c-tabs-list>
17+
<c-tabs-content>
18+
<c-tab-panel itemKey="test-0">Tab panel 0 content</c-tab-panel>
19+
<c-tab-panel itemKey="test-1">Tab panel 1 content</c-tab-panel>
20+
</c-tabs-content>
21+
</c-tabs>
22+
`,
23+
standalone: true,
24+
imports: [TabPanelComponent, TabsComponent, TabDirective, TabsContentComponent, TabsListComponent]
25+
})
26+
class TestComponent {}
27+
28+
describe('TabPanelComponent', () => {
29+
let component: TestComponent;
30+
let fixture: ComponentFixture<TestComponent>;
31+
32+
beforeEach(async () => {
33+
const fixture = TestBed.configureTestingModule({
34+
imports: [TestComponent, NoopAnimationsModule]
35+
}).createComponent(TestComponent);
36+
fixture.detectChanges();
37+
38+
component = fixture.componentInstance;
39+
fixture.detectChanges();
40+
});
41+
42+
it('should create', () => {
43+
expect(component).toBeTruthy();
44+
});
45+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { animate, AnimationEvent, state, style, transition, trigger } from '@angular/animations';
2+
import {
3+
Component,
4+
computed,
5+
HostBinding,
6+
HostListener,
7+
inject,
8+
input,
9+
InputSignal,
10+
InputSignalWithTransform,
11+
numberAttribute,
12+
output,
13+
OutputEmitterRef,
14+
signal
15+
} from '@angular/core';
16+
import { TabsService } from '../tabs.service';
17+
18+
type AnimateType = 'hide' | 'show';
19+
type VisibleChangeEvent = { itemKey: string | number; visible: boolean };
20+
21+
@Component({
22+
selector: 'c-tab-panel',
23+
standalone: true,
24+
template: '<ng-content />',
25+
host: {
26+
'[class]': 'hostClasses()',
27+
'[tabindex]': 'visible() ? tabindex(): -1',
28+
'[attr.aria-labelledby]': 'attrAriaLabelledBy()',
29+
'[id]': 'propId()',
30+
role: 'tabpanel'
31+
},
32+
animations: [
33+
trigger('fadeInOut', [
34+
state('show', style({ opacity: 1 })),
35+
state('hide', style({ opacity: 0 })),
36+
state('void', style({ opacity: 0 })),
37+
transition('* => *', [animate('150ms linear')])
38+
])
39+
]
40+
})
41+
export class TabPanelComponent {
42+
readonly tabsService = inject(TabsService);
43+
44+
/**
45+
* aria-labelledby attribute
46+
* @type string
47+
* @default undefined
48+
*/
49+
readonly ariaLabelledBy: InputSignal<string | undefined> = input<string | undefined>(undefined, {
50+
alias: 'aria-labelledby'
51+
});
52+
53+
/**
54+
* Element id attribute
55+
* @type string
56+
* @default undefined
57+
*/
58+
readonly id: InputSignal<string | undefined> = input<string>();
59+
60+
/**
61+
* Item key.
62+
* @type string | number
63+
* @required
64+
*/
65+
readonly itemKey: InputSignal<string | number> = input.required();
66+
67+
/**
68+
* tabindex attribute.
69+
* @type number
70+
* @default 0
71+
*/
72+
readonly tabindex: InputSignalWithTransform<number, unknown> = input(0, { transform: numberAttribute });
73+
74+
/**
75+
* Enable fade in transition.
76+
* @type boolean
77+
* @default true
78+
*/
79+
readonly transition: InputSignal<boolean> = input(true);
80+
81+
/**
82+
* visible change output
83+
* @type OutputEmitterRef<VisibleChangeEvent>
84+
*/
85+
readonly visibleChange: OutputEmitterRef<VisibleChangeEvent> = output<VisibleChangeEvent>();
86+
87+
readonly show = signal(false);
88+
89+
readonly visible = computed(() => {
90+
const visible = this.tabsService.activeItemKey() === this.itemKey() && !this.tabsService.activeItem()?.disabled;
91+
this.visibleChange.emit({ itemKey: this.itemKey(), visible });
92+
return visible;
93+
});
94+
95+
readonly propId = computed(() => this.id() ?? `${this.tabsService.id()}-panel-${this.itemKey()}`);
96+
97+
readonly attrAriaLabelledBy = computed(
98+
() => this.ariaLabelledBy() ?? `${this.tabsService.id()}-tab-${this.itemKey()}`
99+
);
100+
101+
readonly hostClasses = computed(() => ({
102+
'tab-pane': true,
103+
active: this.show(),
104+
fade: this.transition(),
105+
show: this.show(),
106+
invisible: this.tabsService.activeItem()?.disabled
107+
}));
108+
109+
@HostBinding('@.disabled')
110+
get animationDisabled(): boolean {
111+
return !this.transition();
112+
}
113+
114+
@HostBinding('@fadeInOut')
115+
get animateType(): AnimateType {
116+
return this.visible() ? 'show' : 'hide';
117+
}
118+
119+
@HostListener('@fadeInOut.done', ['$event'])
120+
onAnimationDone($event: AnimationEvent): void {
121+
this.show.set(this.visible());
122+
}
123+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { ElementRef } from '@angular/core';
2+
import { TestBed } from '@angular/core/testing';
3+
import { TabsService } from '../tabs.service';
4+
import { TabDirective } from './tab.directive';
5+
6+
class MockElementRef extends ElementRef {}
7+
8+
describe('TabDirective', () => {
9+
beforeEach(() => {
10+
TestBed.configureTestingModule({
11+
providers: [TabsService, { provide: ElementRef, useClass: MockElementRef }]
12+
});
13+
});
14+
15+
it('should create an instance', () => {
16+
TestBed.runInInjectionContext(() => {
17+
const directive = new TabDirective();
18+
expect(directive).toBeTruthy();
19+
});
20+
});
21+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { FocusableOption, FocusOrigin } from '@angular/cdk/a11y';
2+
import {
3+
booleanAttribute,
4+
computed,
5+
DestroyRef,
6+
Directive,
7+
effect,
8+
ElementRef,
9+
inject,
10+
Input,
11+
input,
12+
InputSignal,
13+
signal,
14+
untracked
15+
} from '@angular/core';
16+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
17+
import { fromEvent, merge, takeWhile } from 'rxjs';
18+
import { filter, tap } from 'rxjs/operators';
19+
import { TabsService } from '../tabs.service';
20+
21+
@Directive({
22+
selector: 'button[cTab]',
23+
standalone: true,
24+
host: {
25+
'[class]': 'hostClasses()',
26+
type: 'button',
27+
role: 'tab',
28+
'[attr.aria-selected]': 'isActive()',
29+
'[attr.aria-controls]': 'attrAriaControls()',
30+
'[id]': 'propId()',
31+
'[tabindex]': 'isActive() ? 0 : -1'
32+
}
33+
})
34+
export class TabDirective implements FocusableOption {
35+
readonly #destroyRef = inject(DestroyRef);
36+
readonly #elementRef = inject(ElementRef);
37+
readonly #tabsService = inject(TabsService);
38+
39+
/**
40+
* Disabled attribute
41+
* @type boolean
42+
* @default false
43+
*/
44+
@Input({ transform: booleanAttribute })
45+
set disabled(value: boolean) {
46+
this.#disabled.set(value);
47+
}
48+
49+
get disabled() {
50+
return this.#disabled();
51+
}
52+
53+
readonly #disabled = signal(false);
54+
55+
/**
56+
* Item key.
57+
* @type string | number
58+
* @required
59+
*/
60+
readonly itemKey: InputSignal<string | number> = input.required<string | number>();
61+
62+
/**
63+
* Element id attribute
64+
* @type string
65+
* @default undefined
66+
*/
67+
readonly id: InputSignal<string | undefined> = input<string>();
68+
69+
/**
70+
* aria-controls attribute
71+
* @type string
72+
* @default undefined
73+
*/
74+
readonly ariaControls: InputSignal<string | undefined> = input<string | undefined>(undefined, {
75+
alias: 'aria-controls'
76+
});
77+
78+
readonly isActive = computed<boolean>(
79+
() => !this.#disabled() && this.#tabsService.activeItemKey() === this.itemKey()
80+
);
81+
82+
readonly hostClasses = computed(() => ({
83+
'nav-link': true,
84+
active: this.isActive()
85+
}));
86+
87+
readonly propId = computed(() => this.id() ?? `${this.#tabsService.id()}-tab-${this.itemKey()}`);
88+
89+
readonly attrAriaControls = computed(
90+
() => this.ariaControls() ?? `${this.#tabsService.id()}-panel-${this.itemKey()}`
91+
);
92+
93+
disabledEffect = effect(
94+
() => {
95+
if (!this.#disabled()) {
96+
const click$ = fromEvent<MouseEvent>(this.#elementRef.nativeElement, 'click');
97+
const focusIn$ = fromEvent<FocusEvent>(this.#elementRef.nativeElement, 'focusin');
98+
99+
merge(focusIn$, click$)
100+
.pipe(
101+
filter(($event) => !this.#disabled()),
102+
tap(($event) => {
103+
this.#tabsService.activeItemKey.set(untracked(this.itemKey));
104+
}),
105+
takeWhile(() => !this.#disabled()),
106+
takeUntilDestroyed(this.#destroyRef)
107+
)
108+
.subscribe();
109+
}
110+
},
111+
{ allowSignalWrites: true }
112+
);
113+
114+
focus(origin?: FocusOrigin): void {
115+
this.#elementRef.nativeElement.focus();
116+
}
117+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { ComponentFixture, TestBed } from '@angular/core/testing';
2+
3+
import { TabsContentComponent } from './tabs-content.component';
4+
5+
describe('TabsContentComponent', () => {
6+
let component: TabsContentComponent;
7+
let fixture: ComponentFixture<TabsContentComponent>;
8+
9+
beforeEach(async () => {
10+
await TestBed.configureTestingModule({
11+
imports: [TabsContentComponent]
12+
}).compileComponents();
13+
14+
fixture = TestBed.createComponent(TabsContentComponent);
15+
component = fixture.componentInstance;
16+
fixture.detectChanges();
17+
});
18+
19+
it('should create', () => {
20+
expect(component).toBeTruthy();
21+
});
22+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Component } from '@angular/core';
2+
3+
@Component({
4+
selector: 'c-tabs-content',
5+
standalone: true,
6+
template: '<ng-content />',
7+
host: {
8+
class: 'tab-content'
9+
}
10+
})
11+
export class TabsContentComponent {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { ComponentFixture, TestBed } from '@angular/core/testing';
2+
3+
import { TabsListComponent } from './tabs-list.component';
4+
import { TabsService } from '../tabs.service';
5+
6+
describe('TabsListComponent', () => {
7+
let component: TabsListComponent;
8+
let fixture: ComponentFixture<TabsListComponent>;
9+
10+
beforeEach(async () => {
11+
await TestBed.configureTestingModule({
12+
imports: [TabsListComponent],
13+
providers: [TabsService]
14+
}).compileComponents();
15+
16+
fixture = TestBed.createComponent(TabsListComponent);
17+
component = fixture.componentInstance;
18+
fixture.detectChanges();
19+
});
20+
21+
it('should create', () => {
22+
expect(component).toBeTruthy();
23+
});
24+
});

0 commit comments

Comments
 (0)
Please sign in to comment.