@libs-ui/components-tabs
v0.2.357-4
Published
> Angular Standalone Component hiển thị thanh Tabs với hỗ trợ đầy đủ Angular Signals, Responsive "More" Menu tự động, Drag & Drop và nhiều chế độ căn chỉnh.
Readme
@libs-ui/components-tabs
Angular Standalone Component hiển thị thanh Tabs với hỗ trợ đầy đủ Angular Signals, Responsive "More" Menu tự động, Drag & Drop và nhiều chế độ căn chỉnh.
Giới thiệu
LibsUiComponentsTabsComponent là một standalone Angular component được xây dựng hoàn toàn trên Angular Signals. Component tự động tính toán và ẩn các tab không vừa chiều ngang vào menu "More" (chế độ left), hỗ trợ kéo thả để sắp xếp lại thứ tự, cho phép kiểm soát chuyển tab bằng callback bất đồng bộ, và cung cấp FunctionsControl để điều khiển từ component cha qua viewChild.
Tính năng
- ✅ Angular Signals: Full support —
itemslàWritableSignal<Array<WritableSignal<ITabsItem>>>, phản ứng ngay khi signal bên trong thay đổi. - ✅ Responsive "More" Menu: Chế độ
lefttự động ẩn các tab thừa vào popover "Xem thêm" khi container hẹp. - ✅ Calculator V2: Thuật toán tính chiều rộng thế hệ mới dùng
ResizeObserver, không flicker, không reorder DOM. - ✅ Drag & Drop: Hỗ trợ kéo thả để thay đổi vị trí tab (tích hợp
@libs-ui/components-drag-drop). - ✅ 4 chế độ hiển thị:
left,center,space-between,center-has-line. - ✅ Rich Content: Mỗi tab item hỗ trợ icon trái/phải, badge số lượng, red dot, ảnh avatar, nút action trái/phải.
- ✅ Guard chuyển tab: Input
checkCanChangeTabSelectedcho phép chặn hoặc xác nhận trước khi đổi tab (hỗ trợ async). - ✅ FunctionsControl: Expose API
addTabsItem,selectedTabsItem,calculatorTabsItemsDisplayra ngoài quaoutFunctionsControl. - ✅ OnPush Change Detection: Tối ưu hiệu năng cho danh sách tab lớn.
Khi nào sử dụng
- Phân chia nội dung thành nhiều view/module trong cùng một màn hình (trang chi tiết, dashboard).
- Thanh điều hướng ngang với số tab không cố định — tính năng "More" sẽ gom phần thừa tự động.
- Tab có thể thêm/xóa động (browser-like tabs) — dùng
FunctionsControl.addTabsItem. - Cần quy trình dạng bước (step wizard) — dùng
mode="center-has-line"kết hợphasStep. - Danh sách tab cần sắp xếp lại bằng kéo thả.
Cài đặt
npm install @libs-ui/components-tabsImport
import { LibsUiComponentsTabsComponent } from '@libs-ui/components-tabs';
import { ITabs, ITabsItem, ITabsFunctionControlEvent, ITabsItemEvent, ITabCssConfig, TYPE_TAB_MODE } from '@libs-ui/components-tabs';
@Component({
standalone: true,
imports: [LibsUiComponentsTabsComponent],
})
export class YourComponent {}Ví dụ sử dụng
Ví dụ 1 — Basic (tabs căn trái, responsive tự động)
import { signal, WritableSignal } from '@angular/core';
import { ITabs, ITabsItem, LibsUiComponentsTabsComponent } from '@libs-ui/components-tabs';
@Component({
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [LibsUiComponentsTabsComponent],
template: `
<libs_ui-components-tabs
[tabs]="tabsConfig"
[(keySelected)]="selectedKey"
(outKeySelected)="handlerKeySelected($event)"
/>
<p>Tab đang chọn: {{ selectedKey() }}</p>
`,
})
export class BasicTabsComponent {
protected selectedKey = signal<string>('overview');
protected tabsConfig: ITabs = {
items: signal<Array<WritableSignal<ITabsItem>>>([
signal({ key: 'overview', label: 'Tổng quan' }),
signal({ key: 'detail', label: 'Chi tiết' }),
signal({ key: 'history', label: 'Lịch sử' }),
signal({ key: 'setting', label: 'Cài đặt', disable: true }),
]),
};
protected handlerKeySelected(key: string): void {
// event.stopPropagation() không cần ở đây vì outKeySelected là OutputEmitterRef
console.log('Tab đã chọn:', key);
}
}Ví dụ 2 — Center mode với icon và badge
import { signal, WritableSignal } from '@angular/core';
import { ITabs, ITabsItem, LibsUiComponentsTabsComponent } from '@libs-ui/components-tabs';
@Component({
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [LibsUiComponentsTabsComponent],
template: `
<libs_ui-components-tabs
[tabs]="tabsConfig"
[(keySelected)]="selectedKey"
mode="center"
/>
`,
})
export class CenterTabsComponent {
protected selectedKey = signal<string>('messages');
protected tabsConfig: ITabs = {
hasCount: true,
items: signal<Array<WritableSignal<ITabsItem>>>([
signal({
key: 'messages',
label: 'Tin nhắn',
iconLeft: 'libs-ui-icon-mail',
count: 5,
classCircle: 'bg-red-500 text-white',
}),
signal({
key: 'notifications',
label: 'Thông báo',
iconLeft: 'libs-ui-icon-bell',
count: 99,
modeCount: 'x+',
maxCount: 9,
classCircle: 'bg-blue-500 text-white',
}),
signal({
key: 'profile',
label: 'Hồ sơ',
iconRight: 'libs-ui-icon-user',
hasRedDot: true,
}),
]),
};
}Ví dụ 3 — Drag & Drop với Calculator V2
import { signal, WritableSignal } from '@angular/core';
import { ITabs, ITabsItem, LibsUiComponentsTabsComponent } from '@libs-ui/components-tabs';
@Component({
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [LibsUiComponentsTabsComponent],
template: `
<libs_ui-components-tabs
[tabs]="tabsConfig"
[(keySelected)]="selectedKey"
[allowDragDropPosition]="true"
[useCalculatorV2]="true"
(outDragTabChange)="handlerDragChange()"
/>
`,
})
export class DragTabsComponent {
protected selectedKey = signal<string>('tab1');
protected tabsConfig: ITabs = {
items: signal<Array<WritableSignal<ITabsItem>>>([
signal({ key: 'tab1', label: 'Mục 1' }),
signal({ key: 'tab2', label: 'Mục 2' }),
signal({ key: 'tab3', label: 'Mục 3' }),
signal({ key: 'tab4', label: 'Mục 4' }),
]),
};
protected handlerDragChange(): void {
console.log('Thứ tự tab đã thay đổi');
}
}Ví dụ 4 — Guard chuyển tab (async confirmation)
import { signal, WritableSignal } from '@angular/core';
import { ITabs, ITabsItem, LibsUiComponentsTabsComponent } from '@libs-ui/components-tabs';
@Component({
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [LibsUiComponentsTabsComponent],
template: `
<libs_ui-components-tabs
[tabs]="tabsConfig"
[(keySelected)]="selectedKey"
[checkCanChangeTabSelected]="checkCanChange"
/>
`,
})
export class GuardedTabsComponent {
protected selectedKey = signal<string>('tab1');
protected hasUnsavedChanges = signal<boolean>(true);
protected tabsConfig: ITabs = {
items: signal<Array<WritableSignal<ITabsItem>>>([
signal({ key: 'tab1', label: 'Form nhập liệu' }),
signal({ key: 'tab2', label: 'Xem trước' }),
]),
};
protected checkCanChange = async (): Promise<boolean> => {
if (!this.hasUnsavedChanges()) return true;
return confirm('Bạn có thay đổi chưa lưu. Tiếp tục chuyển tab?');
};
}Ví dụ 5 — FunctionsControl để thêm tab động
import { signal, WritableSignal } from '@angular/core';
import { ITabs, ITabsItem, ITabsFunctionControlEvent, LibsUiComponentsTabsComponent } from '@libs-ui/components-tabs';
@Component({
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [LibsUiComponentsTabsComponent],
template: `
<libs_ui-components-tabs
[tabs]="tabsConfig"
[(keySelected)]="selectedKey"
[useEffectUpdateItems]="true"
(outFunctionsControl)="handlerFunctionsControl($event)"
/>
<button (click)="addNewTab()">Thêm tab</button>
`,
})
export class DynamicTabsComponent {
protected selectedKey = signal<string>('tab1');
private functionsControl: ITabsFunctionControlEvent | undefined;
protected tabsConfig: ITabs = {
allowRemove: true,
items: signal<Array<WritableSignal<ITabsItem>>>([
signal({ key: 'tab1', label: 'Tab đầu tiên' }),
]),
};
protected handlerFunctionsControl(fc: ITabsFunctionControlEvent): void {
this.functionsControl = fc;
}
protected addNewTab(): void {
const key = `tab_${Date.now()}`;
const newItem = signal<ITabsItem>({ key, label: `Tab ${key.slice(-4)}` });
this.functionsControl?.addTabsItem(newItem, true);
}
}Ví dụ 6 — Step mode (quy trình dạng bước)
import { signal, WritableSignal } from '@angular/core';
import { ITabs, ITabsItem, LibsUiComponentsTabsComponent } from '@libs-ui/components-tabs';
@Component({
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [LibsUiComponentsTabsComponent],
template: `
<libs_ui-components-tabs
[tabs]="tabsConfig"
[(keySelected)]="selectedKey"
mode="center-has-line"
[ignoreCalculatorTab]="true"
/>
`,
})
export class StepTabsComponent {
protected selectedKey = signal<string>('step1');
protected tabsConfig: ITabs = {
hasStep: true,
stepCompleted: 1,
items: signal<Array<WritableSignal<ITabsItem>>>([
signal({ key: 'step1', label: 'Thông tin cơ bản' }),
signal({ key: 'step2', label: 'Xác minh' }),
signal({ key: 'step3', label: 'Hoàn tất' }),
]),
};
}@Input()
| Input | Type | Default | Mô tả | Ví dụ |
|---|---|---|---|---|
| tabs | ITabs | Required | Cấu hình chính chứa danh sách items dạng nested Signal. | [tabs]="tabsConfig" |
| keySelected | string (model) | Required | Key của tab đang chọn, hỗ trợ two-way binding. | [(keySelected)]="selectedKey" |
| mode | TYPE_TAB_MODE | 'left' | Chế độ căn chỉnh: 'left', 'center', 'space-between', 'center-has-line'. | mode="center" |
| fieldKey | string | 'key' | Tên trường làm định danh duy nhất trong object item. | [fieldKey]="'id'" |
| fieldLabel | string | 'label' | Tên trường hiển thị nhãn tab (hỗ trợ i18n key). | [fieldLabel]="'name'" |
| disable | boolean | undefined | Vô hiệu hóa toàn bộ component tabs. | [disable]="true" |
| disableLabel | boolean | undefined | Ẩn nhãn văn bản trên tab item. | [disableLabel]="true" |
| heightTabItem | number | 40 | Chiều cao của thanh tab header tính bằng px. | [heightTabItem]="48" |
| ignoreCalculatorTab | boolean | false | Bỏ qua tính toán responsive — dùng khi biết trước số tab vừa vặn. | [ignoreCalculatorTab]="true" |
| size | 'langer' \| 'medium' | 'medium' | Kích thước tổng thể của component. | size="langer" |
| allowDragDropPosition | boolean | undefined | Cho phép kéo thả thay đổi thứ tự tab. | [allowDragDropPosition]="true" |
| zIndex | number | undefined | Z-index áp dụng cho popover More và overlay. | [zIndex]="100" |
| configCss | ITabCssConfig (model) | undefined | Override CSS padding/margin cho các mode. Tự động set nếu không truyền. | [(configCss)]="cssConfig" |
| popoverShowMoreTabItem | IPopover | undefined | Cấu hình vị trí và style cho popover menu "Xem thêm". | [popoverShowMoreTabItem]="popoverCfg" |
| checkCanChangeTabSelected | () => boolean \| Promise<boolean> | undefined | Callback gác cổng trước khi chuyển tab. Trả về false để hủy. | [checkCanChangeTabSelected]="checkFn" |
| useEffectUpdateItems | boolean | false | Dùng effect() để tự động cập nhật danh sách hiển thị khi signal items thay đổi từ bên ngoài. Bật khi tab list là động. | [useEffectUpdateItems]="true" |
| useCalculatorV2 | boolean | false | Bật thuật toán V2 dùng ResizeObserver — không flicker, không reorder DOM. Khuyến nghị cho tab mới. | [useCalculatorV2]="true" |
@Output()
| Output | Type | Mô tả | Handler TS | Binding HTML |
|---|---|---|---|---|
| (outKeySelected) | string | Emit key của tab vừa được chọn. | handlerKeySelected(key: string): void { /* key là tab key */ } | (outKeySelected)="handlerKeySelected($event)" |
| (outFunctionsControl) | ITabsFunctionControlEvent | Emit object chứa API điều khiển tabs: addTabsItem, selectedTabsItem, calculatorTabsItemsDisplay. | handlerFunctionsControl(fc: ITabsFunctionControlEvent): void { this.tabsFc = fc; } | (outFunctionsControl)="handlerFunctionsControl($event)" |
| (outDragTabChange) | void | Emit sau khi người dùng kéo thả hoàn tất, thứ tự items đã cập nhật. | handlerDragChange(): void { /* đọc lại tabs().items() */ } | (outDragTabChange)="handlerDragChange()" |
| (outDisplayMoreItem) | boolean | Emit true khi có tab bị ẩn vào menu More, false khi tất cả tab vừa. | handlerDisplayMore(visible: boolean): void { this.hasMore.set(visible); } | (outDisplayMoreItem)="handlerDisplayMore($event)" |
| (outAction) | ITabsItemEvent | Emit khi người dùng click vào action item (nút remove, configButtonLeft/Right). key là 'remove' hoặc key action tùy chỉnh. | handlerAction(event: ITabsItemEvent): void { if (event.key === 'remove') this.removeTab(event.item); } | (outAction)="handlerAction($event)" |
Types & Interfaces
import {
ITabs,
ITabsItem,
ITabCssConfig,
ITabsFunctionControlEvent,
ITabsItemEvent,
TYPE_TAB_MODE,
} from '@libs-ui/components-tabs';/** Cấu hình tổng thể cho component tabs */
export interface ITabs {
/** Danh sách các tab item dạng nested Signal — BẮT BUỘC */
items: WritableSignal<Array<WritableSignal<ITabsItem>>>;
/** Hiển thị ảnh avatar bên trái nhãn tab */
hasImage?: boolean;
/** Hiển thị badge số đếm bên phải nhãn tab */
hasCount?: boolean;
/** Hiển thị nút xóa (remove) trên mỗi tab */
allowRemove?: boolean;
/** Cấu hình nút xóa (IButton) */
configButtonRemove?: IButton;
/** Chế độ step wizard — hiển thị số thứ tự trên mỗi tab */
hasStep?: boolean;
/** Số bước đã hoàn thành — dùng với hasStep */
stepCompleted?: number;
/** Hiển thị nền cho step đã hoàn thành */
stepHasBackGround?: boolean;
/** Bỏ qua nền cho tab đang selected trong step mode */
ignoreSelectedBackgroundStep?: boolean;
/** Ẩn đường kẻ dưới trên tab header */
ignoreShowLineBottomInTab?: boolean;
/** Class CSS tùy chỉnh cho phần header */
classIncludeHeader?: string;
/** Class CSS cho vùng center của header */
classIncludeHeaderCenter?: string;
/** Class CSS cho vùng right của header */
classIncludeHeaderRight?: string;
/** Class CSS áp dụng lên mỗi tab item */
classIncludeItem?: string;
/** Class CSS áp dụng lên tab item đang active */
classIncludeActiveItem?: string;
/** Giới hạn chiều rộng tối đa (px) của nhãn tab */
maxWidthTextLabelItem?: number;
/** Cấu hình action ở góc phải header (popover với danh sách) */
actionRightConfig?: WritableSignal<{
getListViewConfig: TYPE_FUNCTION<WritableSignal<IListConfigItem>>;
config?: WritableSignal<IPopoverOverlay>;
onlyShowWhenHoverItemActive?: boolean;
classInclude?: string;
customView?: () => Observable<string>;
}>;
/** Bỏ qua margin-left cho nút "Xem thêm" */
viewMoreIgnoreMarginLeft?: boolean;
}
/** Cấu hình cho từng tab item */
export interface ITabsItem {
/** Định danh duy nhất của tab */
key?: string;
/** Class CSS bổ sung cho tab item */
classInclude?: string;
/** Vô hiệu hóa tab item này */
disable?: boolean;
/** Nhãn hiển thị (hỗ trợ i18n key) */
label?: string;
/** Class CSS cho nhãn */
classLabel?: string;
/** Icon class bên trái nhãn (vd: 'libs-ui-icon-mail') */
iconLeft?: string;
/** Icon class bên phải nhãn */
iconRight?: string;
/** Hiển thị red dot trên tab */
hasRedDot?: boolean;
/** Số đếm hiển thị dạng badge */
count?: number;
/** Chế độ hiển thị badge: 'x' | '0x' | 'x+' */
modeCount?: TYPE_BADGE_MODE;
/** Số tối đa hiển thị trước khi thêm '+' */
maxCount?: number;
/** Class CSS cho vòng tròn badge */
classCircle?: string;
/** URL ảnh avatar bên trái */
linkImage?: string;
/** URL ảnh fallback khi ảnh chính lỗi */
linkImageError?: string;
/** Trạng thái invalid — đổi màu sang đỏ trong step mode */
invalid?: boolean;
/** Nút action bên phải tab item */
configButtonRight?: IButton;
/** Nút action bên trái tab item */
configButtonLeft?: IButton;
/** Chiều rộng đã đo được — do component tự tính, không set thủ công */
specificWidth?: number;
/** Trạng thái hiển thị — do component tự quản lý */
specificDisplay?: boolean;
/** Thứ tự sắp xếp — do component tự quản lý */
order?: number;
/** Cho phép thêm thuộc tính tuỳ chỉnh */
[param: string]: any;
}
/** Override CSS theo từng mode */
export interface ITabCssConfig {
/** Class áp dụng cho tab đầu tiên */
first: string;
/** Class áp dụng cho các tab còn lại */
other: string;
/** Class áp dụng cho header wrapper */
header?: string;
/** Class áp dụng cho phần center của header */
headerCenter?: string;
}
/** API điều khiển tabs từ bên ngoài (nhận qua outFunctionsControl) */
export interface ITabsFunctionControlEvent {
/** Thêm tab item mới vào danh sách */
addTabsItem: (
item: WritableSignal<ITabsItem>,
selected?: boolean, // true = chuyển sang tab mới ngay
addFirst?: boolean, // true = thêm vào đầu danh sách
indexAdd?: number // vị trí cụ thể để chèn vào
) => Promise<void>;
/** Tính toán lại các tab cần hiển thị (dùng sau khi resize thủ công) */
calculatorTabsItemsDisplay: () => Promise<void>;
/** Chuyển sang tab theo key */
selectedTabsItem: (
key: string,
resetDisable?: boolean // true = bật lại tab dù đang disable
) => Promise<void>;
}
/** Dữ liệu emit từ outAction */
export interface ITabsItemEvent {
/** Key của action: 'remove' hoặc key action từ configButtonLeft/Right */
key: string;
/** Data của tab item liên quan */
item: ITabsItem;
}
/** Chế độ hiển thị của component */
export type TYPE_TAB_MODE = 'left' | 'center' | 'space-between' | 'center-has-line';Lưu ý quan trọng
⚠️ Nested Signals bắt buộc: tabs.items PHẢI là WritableSignal<Array<WritableSignal<ITabsItem>>>. Không truyền plain array — component sẽ không phản ứng khi thêm/xóa item.
⚠️ useEffectUpdateItems khi items thay đổi động: Khi danh sách tab được thay đổi từ bên ngoài component (vd: sau khi nhận dữ liệu từ API hoặc Modal), BẮT BUỘC bật [useEffectUpdateItems]="true" để component tự cập nhật danh sách hiển thị.
⚠️ Calculator V2 khuyến nghị cho code mới: [useCalculatorV2]="true" dùng ResizeObserver thay vì MutationObserver + setTimeout — không flicker, không reorder DOM, an toàn với ChangeDetectionStrategy.OnPush. Mặc định false để giữ backward compatible với code cũ.
⚠️ mode center-has-line cho step wizard: Kết hợp với tabs.hasStep = true và tabs.stepCompleted để hiển thị chỉ báo bước hoàn thành. Nên dùng với [ignoreCalculatorTab]="true" khi số bước cố định.
⚠️ allowRemove cần xử lý outAction: Khi bật tabs.allowRemove, lắng nghe (outAction) để xử lý sự kiện key === 'remove'. Component chỉ emit event, không tự xóa item khỏi danh sách.
⚠️ fieldKey và fieldLabel cho data custom: Khi object item dùng tên trường khác key/label (vd: từ API trả về id/name), phải truyền [fieldKey]="'id'" và [fieldLabel]="'name'" để component đọc đúng trường.
Demo
npx nx serve core-uiTruy cập: http://localhost:4500/tabs
Bao gồm các ví dụ: Basic responsive, Center mode, Rich content (icon + badge), Drag & Drop, và tích hợp Modal V2 để cấu hình danh sách tab động.
Unit Tests
npx nx test components-tabsChạy test cho file cụ thể:
npx nx test components-tabs --testFile=libs-ui/components/tabs/src/tabs.component.spec.ts