@libs-ui/components-inputs-mention
v0.2.357-4
Published
> Angular directive thêm tính năng mention (gợi ý khi gõ ký tự trigger) vào input, textarea hoặc contenteditable elements.
Readme
@libs-ui/components-inputs-mention
Angular directive thêm tính năng mention (gợi ý khi gõ ký tự trigger) vào input, textarea hoặc contenteditable elements.
Giới thiệu
LibsUiComponentsInputsMentionDirective là một Angular standalone directive cho phép người dùng tìm kiếm và chọn các mục từ danh sách khi gõ một ký tự kích hoạt (ví dụ: @, #). Directive hỗ trợ đầy đủ cả input/textarea thông thường lẫn contenteditable elements và các rich text editor như Quill. Danh sách gợi ý được render thông qua dynamic component, tự định vị theo vị trí con trỏ trong văn bản.
Tính năng
- Hỗ trợ nhiều ký tự trigger (
@,#, ...) với cấu hình riêng biệt trong cùng một input - Hoạt động trên
input,textareavà các phần tửcontenteditable - Tích hợp sẵn với Quill editor (tự động phát hiện
.ql-editor) - Tùy chỉnh template hiển thị từng mục trong danh sách gợi ý qua
TemplateRef - Lọc danh sách theo từ khóa tìm kiếm (tích hợp sẵn hoặc custom filter)
- Điều hướng bàn phím: mũi tên lên/xuống, Enter để chọn, Escape để đóng
- Hỗ trợ iframe
- Expose
FunctionControlđể chèn mention thủ công từ bên ngoài - Angular Signals +
ChangeDetectionStrategy.OnPushcho hiệu năng tốt nhất - Tự động dọn dẹp dynamic component khi directive bị destroy
Khi nào sử dụng
- Khi cần tính năng mention trong khung chat hoặc khu vực bình luận
- Khi muốn hỗ trợ phím tắt gắn thẻ người dùng nhanh trong các form nhập liệu
- Khi cần tích hợp mention vào các rich text editor (Quill, contenteditable)
- Khi muốn hỗ trợ nhiều loại trigger khác nhau trong cùng một vùng nhập (
@cho người dùng,#cho tag) - Khi cần chèn mention thủ công từ nút bên ngoài (qua
outFunctionControl)
Cài đặt
npm install @libs-ui/components-inputs-mentionImport
import { LibsUiComponentsInputsMentionDirective } from '@libs-ui/components-inputs-mention';
import { IMentionConfig, IMentionInsert, IMentionFunctionControlEvent } from '@libs-ui/components-inputs-mention';
@Component({
standalone: true,
imports: [LibsUiComponentsInputsMentionDirective],
// ...
})
export class MyComponent {}Ví dụ sử dụng
1. Cơ bản — input với trigger @
import { Component } from '@angular/core';
import { LibsUiComponentsInputsMentionDirective, IMentionConfig } from '@libs-ui/components-inputs-mention';
@Component({
standalone: true,
imports: [LibsUiComponentsInputsMentionDirective],
template: `
<input
type="text"
class="w-full p-2 border rounded"
placeholder="Gõ @ để mention người dùng..."
LibsUiComponentsInputsMentionDirective
[mentionConfig]="basicConfig"
(outSearchTerm)="handlerSearchTerm($event)"
(outItemSelected)="handlerItemSelected($event)"
/>
`,
})
export class CommentInputComponent {
readonly basicConfig: IMentionConfig = {
items: [
{ id: '1', name: 'Anh Tuấn', username: '[email protected]' },
{ id: '2', name: 'Bảo Ngọc', username: '[email protected]' },
{ id: '3', name: 'Công Thành', username: '[email protected]' },
],
triggerChar: '@',
labelKey: 'name',
mentionFilter: (search, items) => {
const s = search.toLowerCase();
return items?.filter((item: any) => item.name.toLowerCase().includes(s));
},
mentionSelect: (item: any, triggerChar) => `${triggerChar}${item.name}`,
};
handlerSearchTerm(term: string | undefined): void {
// term là chuỗi người dùng đang gõ sau @
}
handlerItemSelected(item: unknown): void {
// item là object được chọn từ danh sách
}
}2. Nhiều trigger — @ cho người dùng và # cho tag
import { Component } from '@angular/core';
import { LibsUiComponentsInputsMentionDirective, IMentionConfig } from '@libs-ui/components-inputs-mention';
@Component({
standalone: true,
imports: [LibsUiComponentsInputsMentionDirective],
template: `
<textarea
class="w-full p-2 border rounded min-h-[100px]"
placeholder="Gõ @ cho người dùng, # cho tags..."
LibsUiComponentsInputsMentionDirective
[mentionConfig]="multiConfig"
></textarea>
`,
})
export class MultiTriggerComponent {
readonly users = [
{ id: '1', name: 'Anh Tuấn', username: 'tuan' },
{ id: '2', name: 'Bảo Ngọc', username: 'ngoc' },
];
readonly tags = [
{ id: 't1', name: 'angular', username: 'AG' },
{ id: 't2', name: 'typescript', username: 'TS' },
];
readonly filterByName = (search: string, items?: Array<any>) =>
items?.filter((item) => item.name.toLowerCase().includes(search.toLowerCase()));
readonly multiConfig: IMentionConfig = {
items: this.users,
triggerChar: '@',
labelKey: 'name',
mentionFilter: this.filterByName,
mentionSelect: (item: any, triggerChar) => `${triggerChar}${item.name}`,
mention: [
{
items: this.tags,
triggerChar: '#',
labelKey: 'name',
mentionFilter: this.filterByName,
mentionSelect: (item: any, triggerChar) => `${triggerChar}${item.name}`,
},
],
};
}3. ContentEditable với custom template danh sách
import { Component } from '@angular/core';
import { LibsUiComponentsInputsMentionDirective, IMentionConfig } from '@libs-ui/components-inputs-mention';
@Component({
standalone: true,
imports: [LibsUiComponentsInputsMentionDirective],
template: `
<div
contenteditable="true"
class="w-full p-4 border rounded bg-white min-h-[100px]"
LibsUiComponentsInputsMentionDirective
[mentionConfig]="basicConfig"
[mentionListTemplate]="customTpl"
>
Thử gõ @ ở đây...
</div>
<ng-template #customTpl let-item="item">
<div class="flex items-center gap-3 p-3 hover:bg-blue-50 cursor-pointer">
<div class="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center font-bold">
{{ item.name[0] }}
</div>
<div>
<div class="font-medium text-gray-800 text-sm">{{ item.name }}</div>
<div class="text-xs text-gray-500">{{ item.username }}</div>
</div>
</div>
</ng-template>
`,
})
export class ContentEditableMentionComponent {
readonly basicConfig: IMentionConfig = {
items: [
{ id: '1', name: 'Anh Tuấn', username: '[email protected]' },
{ id: '2', name: 'Bảo Ngọc', username: '[email protected]' },
],
triggerChar: '@',
labelKey: 'name',
mentionFilter: (search, items) =>
items?.filter((item: any) => item.name.toLowerCase().includes(search.toLowerCase())),
mentionSelect: (item: any) => `@${item.name}`,
};
}4. Chèn mention thủ công từ nút bên ngoài
import { Component } from '@angular/core';
import { LibsUiComponentsInputsMentionDirective, IMentionConfig, IMentionFunctionControlEvent } from '@libs-ui/components-inputs-mention';
@Component({
standalone: true,
imports: [LibsUiComponentsInputsMentionDirective],
template: `
<div
contenteditable="true"
class="w-full p-3 border rounded min-h-[80px]"
LibsUiComponentsInputsMentionDirective
[mentionConfig]="config"
(outFunctionControl)="handlerFunctionControl($event)"
></div>
<button
class="mt-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
(click)="handlerAddMention($event)"
>
Thêm @mention
</button>
`,
})
export class ManualMentionComponent {
private mentionControl: IMentionFunctionControlEvent | undefined;
readonly config: IMentionConfig = {
items: [
{ id: '1', name: 'Anh Tuấn', username: 'tuan' },
{ id: '2', name: 'Bảo Ngọc', username: 'ngoc' },
],
triggerChar: '@',
labelKey: 'name',
mentionFilter: (search, items) =>
items?.filter((item: any) => item.name.toLowerCase().includes(search.toLowerCase())),
mentionSelect: (item: any) => `@${item.name}`,
};
handlerFunctionControl(event: IMentionFunctionControlEvent): void {
this.mentionControl = event;
}
handlerAddMention(event: Event): void {
event.stopPropagation();
this.mentionControl?.addMention(false);
}
}5. Lắng nghe sự kiện chèn mention (Rich Text Editor)
import { Component } from '@angular/core';
import { LibsUiComponentsInputsMentionDirective, IMentionConfig, IMentionInsert } from '@libs-ui/components-inputs-mention';
@Component({
standalone: true,
imports: [LibsUiComponentsInputsMentionDirective],
template: `
<div
contenteditable="true"
class="w-full p-3 border rounded min-h-[80px]"
LibsUiComponentsInputsMentionDirective
[mentionConfig]="config"
(outInsertMention)="handlerInsertMention($event)"
(outToggle)="handlerToggle($event)"
></div>
`,
})
export class RteIntegrationComponent {
readonly config: IMentionConfig = {
items: [{ id: '1', name: 'Anh Tuấn', username: '[email protected]' }],
triggerChar: '@',
labelKey: 'name',
mentionFilter: (search, items) =>
items?.filter((item: any) => item.name.toLowerCase().includes(search.toLowerCase())),
mentionSelect: (item: any) => `@${item.name}`,
};
handlerInsertMention(event: IMentionInsert): void {
event; // { data: { id, feId, value }, lengthKey }
// Dùng trong Quill: editor.deleteText(pos - event.lengthKey, event.lengthKey)
// rồi editor.insertEmbed(pos, 'mention', event.data)
}
handlerToggle(isOpen: boolean): void {
// isOpen = true khi danh sách gợi ý hiện, false khi đóng
}
}@Input()
| Input | Type | Default | Mô tả | Ví dụ |
|---|---|---|---|---|
| [timeDelayInit] | number | 0 | Thời gian delay (ms) trước khi directive gắn event listeners. Hữu ích khi host element cần render xong trước. | [timeDelayInit]="500" |
| [mentionConfig] | IMentionConfig | undefined | Cấu hình chính cho mention: danh sách items, ký tự trigger, các tùy chọn filter/select. Bắt buộc phải truyền để directive hoạt động. | [mentionConfig]="myConfig" |
| [mentionListTemplate] | TemplateRef<any> | undefined | Template tùy chỉnh cho từng mục trong danh sách gợi ý. Context: { item, index }. Nếu không truyền, dùng template mặc định (avatar + tên + username). | [mentionListTemplate]="myTpl" |
@Output()
| Output | Type | Mô tả | Handler TS | Binding HTML |
|---|---|---|---|---|
| (outSearchTerm) | string \| undefined | Emits từ khóa người dùng đang gõ sau ký tự trigger. Dùng để load data bất đồng bộ từ API. | handlerSearchTerm(term: string \| undefined): void { term; } | (outSearchTerm)="handlerSearchTerm($event)" |
| (outItemSelected) | unknown | Emits object mục được chọn từ danh sách (trước khi chèn vào input). | handlerItemSelected(item: unknown): void { item; } | (outItemSelected)="handlerItemSelected($event)" |
| (outToggle) | boolean | Emits true khi danh sách gợi ý mở ra, false khi đóng lại. | handlerToggle(isOpen: boolean): void { isOpen; } | (outToggle)="handlerToggle($event)" |
| (outInsertMention) | IMentionInsert | Emits khi một mention được chèn thành công. Dùng cho rich text editor cần xử lý chèn node thủ công. | handlerInsertMention(e: IMentionInsert): void { e.data; e.lengthKey; } | (outInsertMention)="handlerInsertMention($event)" |
| (outFunctionControl) | IMentionFunctionControlEvent | Emits object chứa phương thức điều khiển directive từ bên ngoài (ví dụ: chèn mention thủ công). Emits một lần sau ngAfterViewInit. | handlerFunctionControl(ctrl: IMentionFunctionControlEvent): void { this.ctrl = ctrl; } | (outFunctionControl)="handlerFunctionControl($event)" |
Types & Interfaces
import {
IMentionConfig,
IMentionItem,
IMentionInsert,
IMentionFunctionControlEvent,
INodeInsert,
} from '@libs-ui/components-inputs-mention';/**
* Cấu hình chính truyền vào [mentionConfig].
* Kế thừa IMentionItem và bổ sung các tùy chọn giao diện + đa trigger.
*/
export interface IMentionConfig extends IMentionItem {
/** Tắt style mặc định của danh sách gợi ý */
disableStyle?: boolean;
/** ID của iframe chứa host element (khi dùng trong iframe) */
iframe?: string;
/** Cấu hình cho các trigger bổ sung (ngoài trigger chính) */
mention?: Array<IMentionItem>;
}
/**
* Cấu hình cho từng trigger riêng lẻ.
*/
export interface IMentionItem {
/** Ký tự kích hoạt danh sách gợi ý. Mặc định: '@' */
triggerChar?: string;
/** Tên field trong object dùng làm nhãn hiển thị. Mặc định: 'email' */
labelKey?: string;
/** Tắt tự động sắp xếp danh sách items theo labelKey */
disableSort?: boolean;
/** Tắt bộ lọc nội bộ (dùng khi tự xử lý filter bằng outSearchTerm + API) */
disableSearch?: boolean;
/** Hiển thị danh sách phía trên con trỏ thay vì bên dưới */
dropUp?: boolean;
/** Cho phép nhập khoảng trắng khi đang search mention */
allowSpace?: boolean;
/** Giới hạn số khoảng trắng tối đa trong search query */
limitSpaceSearchQuery?: number;
/** Bao gồm ký tự trigger trong giá trị emit ra outSearchTerm */
returnTrigger?: boolean;
/** Hàm format text được chèn vào input sau khi chọn item */
mentionSelect?: (item: unknown, triggerChar?: string) => string;
/** Hàm lọc danh sách items theo search string */
mentionFilter?: (searchString: string, items?: Array<unknown>) => Array<unknown> | undefined;
/** Sự kiện DOM trên mention span đã chèn kích hoạt mentionActionByEvent */
mentionEventName?: 'click' | 'mouseenter';
/** Callback được gọi khi sự kiện mentionEventName xảy ra trên span mention */
mentionActionByEvent?: (item: unknown, triggerChars?: string) => void;
/** Danh sách items hiển thị trong danh sách gợi ý. Bắt buộc. */
items: Array<Record<string, string | undefined>>;
/** z-index của dropdown danh sách gợi ý. Mặc định: 2500 */
zIndex?: number;
}
/**
* Dữ liệu emit qua outInsertMention khi một mention được chèn thành công.
* Dùng cho rich text editor tích hợp sâu (Quill, ProseMirror).
*/
export interface IMentionInsert {
data: {
/** ID định danh duy nhất (từ item.id hoặc uuid tự sinh) */
id: string;
/** Frontend ID dùng để track node trong DOM */
feId: string;
/** Giá trị text được chèn (kết quả của mentionSelect) */
value: string;
};
/** Số ký tự cần xóa tính từ vị trí con trỏ để thay bằng mention node */
lengthKey: number;
}
/**
* Object emit qua outFunctionControl để điều khiển directive từ bên ngoài.
*/
export interface IMentionFunctionControlEvent {
/**
* Chèn ký tự trigger vào vị trí con trỏ hiện tại và mở danh sách gợi ý.
* @param inputEvent - true nếu gọi từ sự kiện input
*/
addMention: (inputEvent: boolean) => void;
}Cấu hình mặc định (DEFAULT_CONFIG)
Khi không truyền đủ các option, directive áp dụng các giá trị mặc định sau:
| Thuộc tính | Giá trị mặc định |
|---|---|
| triggerChar | '@' |
| labelKey | 'email' |
| allowSpace | true |
| returnTrigger | false |
| limitSpaceSearchQuery | 3 |
| mentionEventName | 'click' |
| mentionSelect | (item) => '@' + item.email |
| mentionFilter | Lọc theo field name và username (hỗ trợ tiếng Việt bỏ dấu) |
| zIndex | 2500 |
Điều hướng bàn phím
| Phím | Hành động |
|---|---|
| @, #, ... (trigger char) | Mở danh sách gợi ý |
| Mũi tên xuống | Chọn item tiếp theo |
| Mũi tên lên | Chọn item trước đó |
| Enter hoặc Tab | Chèn item đang active vào input |
| Escape | Đóng danh sách gợi ý |
| Backspace về trước ký tự trigger | Đóng danh sách gợi ý |
| Space (khi allowSpace: false) | Đóng danh sách gợi ý |
Lưu ý quan trọng
⚠️ mentionConfig là bắt buộc: Nếu không truyền [mentionConfig], directive sẽ không gắn event listeners và không hoạt động. Kiểm tra ngAfterViewInit trong source.
⚠️ ContentEditable vs Input: Logic xử lý caret và chèn mention khác nhau giữa input/textarea và contenteditable. Với input/textarea, directive dùng selectionStart. Với contenteditable, directive dùng window.getSelection() để tính vị trí và chèn HTML node.
⚠️ outInsertMention chỉ emit với contenteditable: Output (outInsertMention) chỉ được emit khi host element là contenteditable (mode editor). Với input/textarea, mention được chèn trực tiếp vào value.
⚠️ Dependency bắt buộc: Directive phụ thuộc vào LibsUiDynamicComponentService để render danh sách gợi ý. Đảm bảo service này được cung cấp trong ứng dụng.
⚠️ mentionFilter phải được truyền để lọc hoạt động: Nếu không truyền mentionFilter, directive dùng hàm mặc định lọc theo field name và username. Nếu data của bạn dùng field khác, hãy truyền hàm mentionFilter riêng.
⚠️ timeDelayInit với lazy-loaded editor: Khi dùng cùng Quill hoặc editor khởi tạo bất đồng bộ, truyền [timeDelayInit]="500" hoặc giá trị phù hợp để đảm bảo editor đã render xong trước khi directive gắn event.
⚠️ disableSearch để load data từ API: Khi muốn tự load danh sách từ API dựa trên từ khóa, set disableSearch: true trong config và lắng nghe (outSearchTerm) để gọi API. Sau khi nhận kết quả, cập nhật mentionConfig.items để danh sách hiển thị mới.
Demo
npx nx serve core-uiTruy cập: http://localhost:4500/inputs/mention
