From c96c71667c6f5ad5a30d1ec0d78f240c493932a8 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Mon, 29 Sep 2025 13:18:55 +0300 Subject: [PATCH 1/4] feat: Added chat component Angular wrapper --- package-lock.json | 235 ++++++------------ package.json | 2 +- .../src/lib/chat/chat.component.html | 13 + .../src/lib/chat/chat.component.ts | 202 +++++++++++++++ 4 files changed, 297 insertions(+), 155 deletions(-) create mode 100644 projects/igniteui-angular/src/lib/chat/chat.component.html create mode 100644 projects/igniteui-angular/src/lib/chat/chat.component.ts diff --git a/package-lock.json b/package-lock.json index d1b5e36f542..f9300e50529 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,7 +71,7 @@ "ig-typedoc-theme": "^6.0.0", "igniteui-dockmanager": "^1.17.0", "igniteui-sassdoc-theme": "^2.0.2", - "igniteui-webcomponents": "6.2.1", + "igniteui-webcomponents": "^6.3.1", "jasmine": "^5.6.0", "jasmine-core": "^5.6.0", "karma": "^6.4.4", @@ -613,18 +613,6 @@ } } }, - "node_modules/@angular/build/node_modules/@types/node": { - "version": "24.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.0.tgz", - "integrity": "sha512-y1dMvuvJspJiPSDZUQ+WMBvF7dpnEqN4x9DDC9ie5Fs/HUZJA3wFp7EhHoVaKX/iI0cRoECV8X2jL8zi0xrHCg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "undici-types": "~7.12.0" - } - }, "node_modules/@angular/build/node_modules/sass": { "version": "1.90.0", "resolved": "https://registry.npmjs.org/sass/-/sass-1.90.0.tgz", @@ -646,15 +634,6 @@ "@parcel/watcher": "^2.4.1" } }, - "node_modules/@angular/build/node_modules/undici-types": { - "version": "7.12.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", - "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@angular/build/node_modules/vite": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", @@ -5775,22 +5754,22 @@ } }, "node_modules/@shikijs/core": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.6.0.tgz", - "integrity": "sha512-9By7Xb3olEX0o6UeJyPLI1PE1scC4d3wcVepvtv2xbuN9/IThYN4Wcwh24rcFeASzPam11MCq8yQpwwzCgSBRw==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.13.0.tgz", + "integrity": "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA==", "dev": true, "license": "MIT", "dependencies": { - "@shikijs/types": "3.6.0", + "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "node_modules/@shikijs/core/node_modules/@shikijs/types": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.6.0.tgz", - "integrity": "sha512-cLWFiToxYu0aAzJqhXTQsFiJRTFDAGl93IrMSBNaGSzs7ixkLfdG6pH11HipuWFGW5vyx4X47W8HDQ7eSrmBUg==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.13.0.tgz", + "integrity": "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw==", "dev": true, "license": "MIT", "dependencies": { @@ -5799,21 +5778,21 @@ } }, "node_modules/@shikijs/engine-javascript": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.6.0.tgz", - "integrity": "sha512-7YnLhZG/TU05IHMG14QaLvTW/9WiK8SEYafceccHUSXs2Qr5vJibUwsDfXDLmRi0zHdzsxrGKpSX6hnqe0k8nA==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.13.0.tgz", + "integrity": "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg==", "dev": true, "license": "MIT", "dependencies": { - "@shikijs/types": "3.6.0", + "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "node_modules/@shikijs/engine-javascript/node_modules/@shikijs/types": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.6.0.tgz", - "integrity": "sha512-cLWFiToxYu0aAzJqhXTQsFiJRTFDAGl93IrMSBNaGSzs7ixkLfdG6pH11HipuWFGW5vyx4X47W8HDQ7eSrmBUg==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.13.0.tgz", + "integrity": "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw==", "dev": true, "license": "MIT", "dependencies": { @@ -5833,19 +5812,19 @@ } }, "node_modules/@shikijs/langs": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.6.0.tgz", - "integrity": "sha512-IdZkQJaLBu1LCYCwkr30hNuSDfllOT8RWYVZK1tD2J03DkiagYKRxj/pDSl8Didml3xxuyzUjgtioInwEQM/TA==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.13.0.tgz", + "integrity": "sha512-672c3WAETDYHwrRP0yLy3W1QYB89Hbpj+pO4KhxK6FzIrDI2FoEXNiNCut6BQmEApYLfuYfpgOZaqbY+E9b8wQ==", "dev": true, "license": "MIT", "dependencies": { - "@shikijs/types": "3.6.0" + "@shikijs/types": "3.13.0" } }, "node_modules/@shikijs/langs/node_modules/@shikijs/types": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.6.0.tgz", - "integrity": "sha512-cLWFiToxYu0aAzJqhXTQsFiJRTFDAGl93IrMSBNaGSzs7ixkLfdG6pH11HipuWFGW5vyx4X47W8HDQ7eSrmBUg==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.13.0.tgz", + "integrity": "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw==", "dev": true, "license": "MIT", "dependencies": { @@ -5854,19 +5833,19 @@ } }, "node_modules/@shikijs/themes": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.6.0.tgz", - "integrity": "sha512-Fq2j4nWr1DF4drvmhqKq8x5vVQ27VncF8XZMBuHuQMZvUSS3NBgpqfwz/FoGe36+W6PvniZ1yDlg2d4kmYDU6w==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.13.0.tgz", + "integrity": "sha512-Vxw1Nm1/Od8jyA7QuAenaV78BG2nSr3/gCGdBkLpfLscddCkzkL36Q5b67SrLLfvAJTOUzW39x4FHVCFriPVgg==", "dev": true, "license": "MIT", "dependencies": { - "@shikijs/types": "3.6.0" + "@shikijs/types": "3.13.0" } }, "node_modules/@shikijs/themes/node_modules/@shikijs/types": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.6.0.tgz", - "integrity": "sha512-cLWFiToxYu0aAzJqhXTQsFiJRTFDAGl93IrMSBNaGSzs7ixkLfdG6pH11HipuWFGW5vyx4X47W8HDQ7eSrmBUg==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.13.0.tgz", + "integrity": "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw==", "dev": true, "license": "MIT", "dependencies": { @@ -13648,9 +13627,9 @@ } }, "node_modules/igniteui-webcomponents": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/igniteui-webcomponents/-/igniteui-webcomponents-6.2.1.tgz", - "integrity": "sha512-nsErVEF/2nuU76w8pkDzdu+0Xwv25OYWVDdXP5dFoQwvLMusNFju273e8c+DV9LoPtD0nWx6+RzyNaS+ylWXjw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/igniteui-webcomponents/-/igniteui-webcomponents-6.3.1.tgz", + "integrity": "sha512-t0D5xpBmtLLaPGAdfsX+u/nyTBUPm4W13sx777YiiTBH5MpYqEkrLirYi1GTJNQ6v7prepUuGOCj7yuSx/MJ3g==", "dev": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { @@ -13661,6 +13640,26 @@ }, "engines": { "node": ">=20" + }, + "peerDependencies": { + "dompurify": "^3.2.0", + "marked": "^16.3.0", + "marked-shiki": "^1.2.0", + "shiki": "^3.12.0" + }, + "peerDependenciesMeta": { + "dompurify": { + "optional": true + }, + "marked": { + "optional": true + }, + "marked-shiki": { + "optional": true + }, + "shiki": { + "optional": true + } } }, "node_modules/ignore": { @@ -16292,19 +16291,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/marked": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/marked/-/marked-0.6.3.tgz", - "integrity": "sha512-Fqa7eq+UaxfMriqzYLayfqAE40WN03jf+zHjT18/uXNuzjq3TY0XTbrAoPeqSJrAmPz11VuUA+kBPYOhHt9oOQ==", - "dev": true, - "license": "MIT", - "bin": { - "marked": "bin/marked" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -21236,6 +21222,19 @@ "marked": "^0.6.2" } }, + "node_modules/sassdoc-extras/node_modules/marked": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/marked/-/marked-0.6.3.tgz", + "integrity": "sha512-Fqa7eq+UaxfMriqzYLayfqAE40WN03jf+zHjT18/uXNuzjq3TY0XTbrAoPeqSJrAmPz11VuUA+kBPYOhHt9oOQ==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sassdoc-plugin-localization": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/sassdoc-plugin-localization/-/sassdoc-plugin-localization-2.0.0.tgz", @@ -21299,33 +21298,6 @@ "sassdoc-extras": "^2.5.0" } }, - "node_modules/sassdoc-theme-default/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/sassdoc-theme-default/node_modules/commander": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", @@ -21354,21 +21326,6 @@ "jsonfile": "^2.1.0" } }, - "node_modules/sassdoc-theme-default/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/sassdoc-theme-default/node_modules/jsonfile": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz", @@ -21405,36 +21362,6 @@ } } }, - "node_modules/sassdoc-theme-default/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/sassdoc-theme-default/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, "node_modules/sassdoc/node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -22254,37 +22181,37 @@ } }, "node_modules/shiki": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.6.0.tgz", - "integrity": "sha512-tKn/Y0MGBTffQoklaATXmTqDU02zx8NYBGQ+F6gy87/YjKbizcLd+Cybh/0ZtOBX9r1NEnAy/GTRDKtOsc1L9w==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.13.0.tgz", + "integrity": "sha512-aZW4l8Og16CokuCLf8CF8kq+KK2yOygapU5m3+hoGw0Mdosc6fPitjM+ujYarppj5ZIKGyPDPP1vqmQhr+5/0g==", "dev": true, "license": "MIT", "dependencies": { - "@shikijs/core": "3.6.0", - "@shikijs/engine-javascript": "3.6.0", - "@shikijs/engine-oniguruma": "3.6.0", - "@shikijs/langs": "3.6.0", - "@shikijs/themes": "3.6.0", - "@shikijs/types": "3.6.0", + "@shikijs/core": "3.13.0", + "@shikijs/engine-javascript": "3.13.0", + "@shikijs/engine-oniguruma": "3.13.0", + "@shikijs/langs": "3.13.0", + "@shikijs/themes": "3.13.0", + "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "node_modules/shiki/node_modules/@shikijs/engine-oniguruma": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.6.0.tgz", - "integrity": "sha512-nmOhIZ9yT3Grd+2plmW/d8+vZ2pcQmo/UnVwXMUXAKTXdi+LK0S08Ancrz5tQQPkxvjBalpMW2aKvwXfelauvA==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.13.0.tgz", + "integrity": "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg==", "dev": true, "license": "MIT", "dependencies": { - "@shikijs/types": "3.6.0", + "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "node_modules/shiki/node_modules/@shikijs/types": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.6.0.tgz", - "integrity": "sha512-cLWFiToxYu0aAzJqhXTQsFiJRTFDAGl93IrMSBNaGSzs7ixkLfdG6pH11HipuWFGW5vyx4X47W8HDQ7eSrmBUg==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.13.0.tgz", + "integrity": "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index db833f8676f..28a78b306a4 100644 --- a/package.json +++ b/package.json @@ -121,7 +121,7 @@ "ig-typedoc-theme": "^6.0.0", "igniteui-dockmanager": "^1.17.0", "igniteui-sassdoc-theme": "^2.0.2", - "igniteui-webcomponents": "6.2.1", + "igniteui-webcomponents": "^6.3.1", "jasmine": "^5.6.0", "jasmine-core": "^5.6.0", "karma": "^6.4.4", diff --git a/projects/igniteui-angular/src/lib/chat/chat.component.html b/projects/igniteui-angular/src/lib/chat/chat.component.html new file mode 100644 index 00000000000..163299e8727 --- /dev/null +++ b/projects/igniteui-angular/src/lib/chat/chat.component.html @@ -0,0 +1,13 @@ + diff --git a/projects/igniteui-angular/src/lib/chat/chat.component.ts b/projects/igniteui-angular/src/lib/chat/chat.component.ts new file mode 100644 index 00000000000..73bba770a92 --- /dev/null +++ b/projects/igniteui-angular/src/lib/chat/chat.component.ts @@ -0,0 +1,202 @@ +import { + ChangeDetectionStrategy, + Component, + CUSTOM_ELEMENTS_SCHEMA, + effect, + inject, + input, + OnInit, + output, + signal, + TemplateRef, + ViewContainerRef, + untracked, + OnDestroy, + ViewRef, +} from '@angular/core'; +import { + IgcChatComponent, + type IgcChatMessageAttachment, + type IgcChatMessage, + type IgcChatOptions, + type ChatRenderContext, + type ChatRenderers, + type ChatAttachmentRenderContext, + type ChatInputRenderContext, + type ChatMessageRenderContext, +} from 'igniteui-webcomponents'; + +type ChatContextUnion = + | ChatAttachmentRenderContext + | ChatMessageRenderContext + | ChatInputRenderContext + | ChatRenderContext; + +type ChatContextType = + T extends ChatAttachmentRenderContext + ? IgcChatMessageAttachment + : T extends ChatMessageRenderContext + ? IgcChatMessage + : T extends ChatInputRenderContext + ? { value: T['value']; attachments: T['attachments'] } + : T extends ChatRenderContext + ? { instance: T['instance'] } + : never; + +type ExtractChatContext = T extends (ctx: infer R) => any ? R : never; + +type ChatTemplatesContextMap = { + [K in keyof ChatRenderers]: { + $implicit: ChatContextType< + ExtractChatContext> & ChatContextUnion + >; + }; +}; + +export type NgChatTemplates = { + [K in keyof ChatRenderers]?: TemplateRef; +}; + +export type NgChatOptions = Omit; + +@Component({ + selector: 'igx-chat', + changeDetection: ChangeDetectionStrategy.OnPush, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + templateUrl: './chat.component.html' +}) +export class IgxChatComponent implements OnInit, OnDestroy { + //#region Internal state + + private readonly _view = inject(ViewContainerRef); + private readonly _templateViewRefs = new Map, Set>(); + + protected readonly _mergedOptions = signal({}); + protected readonly _transformedTemplates = signal({}); + + //#endregion + + //#region Inputs + + public readonly messages = input([]); + public readonly draftMessage = input< + { text: string; attachments?: IgcChatMessageAttachment[] } | undefined + >({ text: '' }); + public readonly options = input({}); + public readonly templates = input({}); + + //#endregion + + //#region Outputs + + public readonly messageCreated = output(); + public readonly attachmentClick = output(); + public readonly attachmentDrag = output(); + public readonly attachmentDrop = output(); + public readonly typingChange = output(); + public readonly inputFocus = output(); + public readonly inputBlur = output(); + public readonly inputChange = output(); + + //#endregion + + public ngOnInit(): void { + IgcChatComponent.register(); + } + + public ngOnDestroy(): void { + for (const viewSet of this._templateViewRefs.values()) { + for (const viewRef of viewSet) { + viewRef.destroy(); + } + } + this._templateViewRefs.clear(); + } + + constructor() { + effect(() => { + const templates = this.templates(); + this._setTemplates(templates); + + this._mergeOptions(untracked(() => this.options())); + }); + + effect(() => { + const options = this.options(); + this._mergeOptions(options); + }); + } + + private _mergeOptions(options: NgChatOptions): void { + const transformedTemplates = this._transformedTemplates(); + const merged: IgcChatOptions = { + ...options, + renderers: transformedTemplates + }; + this._mergedOptions.set(merged); + } + + private _setTemplates(newTemplates: NgChatTemplates): void { + const templateCopies: ChatRenderers = {}; + const newTemplateKeys = Object.keys(newTemplates) as Array; + + const oldTemplates = this.templates(); + const oldTemplateKeys = Object.keys(oldTemplates) as Array; + + for (const key of oldTemplateKeys) { + const oldRef = oldTemplates[key]; + const newRef = newTemplates[key]; + + if (oldRef && oldRef !== newRef) { + const obsolete = this._templateViewRefs.get(oldRef); + if (obsolete) { + obsolete.forEach(viewRef => viewRef.destroy()); + this._templateViewRefs.delete(oldRef); + } + } + } + + if (newTemplateKeys.length > 0) { + for (const key of newTemplateKeys) { + const ref = newTemplates[key]; + if (ref) { + templateCopies[key] = this._createTemplateRenderer(ref); + } + } + } + + this._transformedTemplates.set(templateCopies); + } + + private _createTemplateRenderer(ref: NonNullable) { + type ChatContext = ExtractChatContext>; + + if (!this._templateViewRefs.has(ref)) { + this._templateViewRefs.set(ref, new Set()); + } + + const viewSet = this._templateViewRefs.get(ref)!; + + return (ctx: ChatContext) => { + const context = ctx as ChatContextUnion; + let angularContext: any; + + if ('message' in context && 'attachment' in context) { + angularContext = { $implicit: context.attachment }; + } else if ('message' in context) { + angularContext = { $implicit: context.message }; + } else if ('value' in context) { + angularContext = { + $implicit: { value: context.value, attachments: context.attachments }, + }; + } else { + angularContext = { $implicit: { instance: context.instance } }; + } + + const node = this._view.createEmbeddedView(ref, angularContext); + viewSet.add(node); + + return node.rootNodes; + } + } +} From b1a45c07d2101453fe1ed0822e5e49426b391998 Mon Sep 17 00:00:00 2001 From: Stamen Stoychev Date: Tue, 30 Sep 2025 16:51:43 +0300 Subject: [PATCH 2/4] chore(*): pushing updated package.lock --- package-lock.json | 93 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/package-lock.json b/package-lock.json index f9300e50529..4f2e7b11984 100644 --- a/package-lock.json +++ b/package-lock.json @@ -613,6 +613,18 @@ } } }, + "node_modules/@angular/build/node_modules/@types/node": { + "version": "24.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.0.tgz", + "integrity": "sha512-F1CBxgqwOMc4GKJ7eY22hWhBVQuMYTtqI8L0FcszYcpYX0fzfDGpez22Xau8Mgm7O9fI+zA/TYIdq3tGWfweBA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "undici-types": "~7.13.0" + } + }, "node_modules/@angular/build/node_modules/sass": { "version": "1.90.0", "resolved": "https://registry.npmjs.org/sass/-/sass-1.90.0.tgz", @@ -634,6 +646,15 @@ "@parcel/watcher": "^2.4.1" } }, + "node_modules/@angular/build/node_modules/undici-types": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.13.0.tgz", + "integrity": "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@angular/build/node_modules/vite": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", @@ -21298,6 +21319,33 @@ "sassdoc-extras": "^2.5.0" } }, + "node_modules/sassdoc-theme-default/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/sassdoc-theme-default/node_modules/commander": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", @@ -21326,6 +21374,21 @@ "jsonfile": "^2.1.0" } }, + "node_modules/sassdoc-theme-default/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/sassdoc-theme-default/node_modules/jsonfile": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz", @@ -21362,6 +21425,36 @@ } } }, + "node_modules/sassdoc-theme-default/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/sassdoc-theme-default/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/sassdoc/node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", From e0939f404ba5a78b2bb548621c294cf729c518d8 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 30 Sep 2025 17:01:45 +0300 Subject: [PATCH 3/4] fix: Old templates refs handling when there is a dynamic change Added unit tests --- .../src/lib/chat/chat.component.ts | 19 +-- .../src/lib/chat/chat.spec.ts | 112 ++++++++++++++++++ 2 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 projects/igniteui-angular/src/lib/chat/chat.spec.ts diff --git a/projects/igniteui-angular/src/lib/chat/chat.component.ts b/projects/igniteui-angular/src/lib/chat/chat.component.ts index 73bba770a92..8c9a155e57f 100644 --- a/projects/igniteui-angular/src/lib/chat/chat.component.ts +++ b/projects/igniteui-angular/src/lib/chat/chat.component.ts @@ -70,6 +70,7 @@ export class IgxChatComponent implements OnInit, OnDestroy { private readonly _view = inject(ViewContainerRef); private readonly _templateViewRefs = new Map, Set>(); + private _oldTemplates: NgChatTemplates = {}; protected readonly _mergedOptions = signal({}); protected readonly _transformedTemplates = signal({}); @@ -100,20 +101,21 @@ export class IgxChatComponent implements OnInit, OnDestroy { //#endregion + /** @internal */ public ngOnInit(): void { IgcChatComponent.register(); } + /** @internal */ public ngOnDestroy(): void { for (const viewSet of this._templateViewRefs.values()) { - for (const viewRef of viewSet) { - viewRef.destroy(); - } + viewSet.forEach(viewRef => viewRef.destroy()); } this._templateViewRefs.clear(); } constructor() { + // Templates changed - update transformed templates and viewRefs and merge with options effect(() => { const templates = this.templates(); this._setTemplates(templates); @@ -121,6 +123,7 @@ export class IgxChatComponent implements OnInit, OnDestroy { this._mergeOptions(untracked(() => this.options())); }); + // Options changed - merge with current template state effect(() => { const options = this.options(); this._mergeOptions(options); @@ -140,7 +143,7 @@ export class IgxChatComponent implements OnInit, OnDestroy { const templateCopies: ChatRenderers = {}; const newTemplateKeys = Object.keys(newTemplates) as Array; - const oldTemplates = this.templates(); + const oldTemplates = this._oldTemplates; const oldTemplateKeys = Object.keys(oldTemplates) as Array; for (const key of oldTemplateKeys) { @@ -157,9 +160,11 @@ export class IgxChatComponent implements OnInit, OnDestroy { } if (newTemplateKeys.length > 0) { + this._oldTemplates = {}; for (const key of newTemplateKeys) { const ref = newTemplates[key]; if (ref) { + this._oldTemplates[key] = ref as any; templateCopies[key] = this._createTemplateRenderer(ref); } } @@ -193,10 +198,10 @@ export class IgxChatComponent implements OnInit, OnDestroy { angularContext = { $implicit: { instance: context.instance } }; } - const node = this._view.createEmbeddedView(ref, angularContext); - viewSet.add(node); + const viewRef = this._view.createEmbeddedView(ref, angularContext); + viewSet.add(viewRef); - return node.rootNodes; + return viewRef.rootNodes; } } } diff --git a/projects/igniteui-angular/src/lib/chat/chat.spec.ts b/projects/igniteui-angular/src/lib/chat/chat.spec.ts new file mode 100644 index 00000000000..4aee9ddd7c7 --- /dev/null +++ b/projects/igniteui-angular/src/lib/chat/chat.spec.ts @@ -0,0 +1,112 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing' +import { IgxChatComponent } from './chat.component' +import { Component, signal, TemplateRef, viewChild } from '@angular/core'; +import type { IgcChatMessage } from 'igniteui-webcomponents'; + +describe('Chat wrapper', () => { + function getShadowRoot(element: HTMLElement) { + return element.shadowRoot; + } + + let chatComponent: IgxChatComponent; + let chatElement: HTMLElement; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [IgxChatComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(IgxChatComponent); + chatComponent = fixture.componentInstance; + chatElement = (fixture.nativeElement as HTMLElement).querySelector('igc-chat'); + fixture.detectChanges(); + }) + + it('is created', () => { + expect(chatComponent).toBeDefined(); + }); + + it('has correct initial empty state', () => { + const draft = chatComponent.draftMessage(); + + expect(chatComponent.messages().length).toEqual(0); + expect(draft.text).toEqual(''); + expect(draft.attachments).toBeUndefined(); + }); + + it('correct bindings for messages', async () => { + fixture.componentRef.setInput('messages', [{ id: '1', sender: 'user', text: 'Hello' }]); + + fixture.detectChanges(); + await fixture.whenStable(); + + const messageElement = getShadowRoot(chatElement).querySelector('igc-chat-message'); + expect(messageElement).toBeDefined(); + expect(getShadowRoot(messageElement).textContent.trim()).toEqual(chatComponent.messages()[0].text); + }); + + it('correct bindings for draft message', async () => { + fixture.componentRef.setInput('draftMessage', { text: 'Hello world' }); + + fixture.detectChanges(); + await fixture.whenStable(); + + const textarea = getShadowRoot(getShadowRoot(chatElement).querySelector('igc-chat-input')).querySelector('igc-textarea'); + expect(textarea.value).toEqual(chatComponent.draftMessage().text); + }); +}); + +describe('Chat templates', () => { + function getShadowRoot(element: HTMLElement) { + return element.shadowRoot; + } + + let fixture: ComponentFixture; + let chatElement: HTMLElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [IgxChatComponent, ChatTemplatesBed] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ChatTemplatesBed); + fixture.detectChanges(); + chatElement = (fixture.nativeElement as HTMLElement).querySelector('igc-chat'); + }); + + it('has correct initially bound template', async () => { + await fixture.whenStable(); + + // NOTE: This is invoked since in the test bed there is no app ref so fresh embedded view + // has no change detection ran on it. In an application scenario this is not the case. + // This is so we don't explicitly invoke `viewRef.detectChanges()` inside the returned closure + // from the wrapper's `_createTemplateRenderer` call. + fixture.detectChanges(); + expect(getShadowRoot(getShadowRoot(chatElement).querySelector('igc-chat-message')).textContent.trim()) + .toEqual(`Your message: ${fixture.componentInstance.messages()[0].text}`); + }); +}); + + +@Component({ + template: ` + + +

Your message: {{ message.text }}

+
+ `, + imports: [IgxChatComponent] +}) +class ChatTemplatesBed { + public messages = signal([{ + id: '1', + sender: 'user', + text: 'Hello world' + }]); + public messageTemplate = viewChild.required>('message'); +} From 91e241c0f96bbc4593300359e856e4e5be1476fb Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Fri, 3 Oct 2025 10:47:53 +0300 Subject: [PATCH 4/4] feat: Added chat template directives --- .../src/lib/chat/chat.component.ts | 42 ++++++- .../src/lib/chat/chat.spec.ts | 105 ++++++++++++++---- 2 files changed, 123 insertions(+), 24 deletions(-) diff --git a/projects/igniteui-angular/src/lib/chat/chat.component.ts b/projects/igniteui-angular/src/lib/chat/chat.component.ts index 8c9a155e57f..7536994c7b0 100644 --- a/projects/igniteui-angular/src/lib/chat/chat.component.ts +++ b/projects/igniteui-angular/src/lib/chat/chat.component.ts @@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, CUSTOM_ELEMENTS_SCHEMA, + Directive, effect, inject, input, @@ -59,11 +60,12 @@ export type NgChatTemplates = { export type NgChatOptions = Omit; + @Component({ selector: 'igx-chat', changeDetection: ChangeDetectionStrategy.OnPush, schemas: [CUSTOM_ELEMENTS_SCHEMA], - templateUrl: './chat.component.html' + templateUrl: './chat.component.html', }) export class IgxChatComponent implements OnInit, OnDestroy { //#region Internal state @@ -118,15 +120,14 @@ export class IgxChatComponent implements OnInit, OnDestroy { // Templates changed - update transformed templates and viewRefs and merge with options effect(() => { const templates = this.templates(); - this._setTemplates(templates); - + this._setTemplates(templates ?? {}); this._mergeOptions(untracked(() => this.options())); }); // Options changed - merge with current template state effect(() => { const options = this.options(); - this._mergeOptions(options); + this._mergeOptions(options ?? {}); }); } @@ -205,3 +206,36 @@ export class IgxChatComponent implements OnInit, OnDestroy { } } } + +export interface ChatTemplateContext { + $implicit: T; +} + +interface ChatInputContext { + $implicit: string; + attachments: IgcChatMessageAttachment[]; +} + +@Directive({ selector: '[igxChatMessageContext]' }) +export class IgxChatMessageContextDirective { + + public static ngTemplateContextGuard(_: IgxChatMessageContextDirective, ctx: unknown): ctx is ChatTemplateContext { + return true; + } +}; + +@Directive({ selector: '[igxChatAttachmentContext]' }) +export class IgxChatAttachmentContextDirective { + + public static ngTemplateContextGuard(_: IgxChatAttachmentContextDirective, ctx: unknown): ctx is ChatTemplateContext { + return true; + } +} + +@Directive({ selector: '[igxChatInputContext]' }) +export class IgxChatInputContextDirective { + + public static ngTemplateContextGuard(_: IgxChatInputContextDirective, ctx: unknown): ctx is ChatInputContext { + return true; + } +} diff --git a/projects/igniteui-angular/src/lib/chat/chat.spec.ts b/projects/igniteui-angular/src/lib/chat/chat.spec.ts index 4aee9ddd7c7..157a0f1cdf1 100644 --- a/projects/igniteui-angular/src/lib/chat/chat.spec.ts +++ b/projects/igniteui-angular/src/lib/chat/chat.spec.ts @@ -1,15 +1,12 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing' -import { IgxChatComponent } from './chat.component' +import { IgxChatComponent, IgxChatMessageContextDirective, NgChatTemplates } from './chat.component' import { Component, signal, TemplateRef, viewChild } from '@angular/core'; -import type { IgcChatMessage } from 'igniteui-webcomponents'; +import type { IgcChatComponent, IgcChatMessage, IgcTextareaComponent } from 'igniteui-webcomponents'; describe('Chat wrapper', () => { - function getShadowRoot(element: HTMLElement) { - return element.shadowRoot; - } let chatComponent: IgxChatComponent; - let chatElement: HTMLElement; + let chatElement: IgcChatComponent; let fixture: ComponentFixture; beforeEach(waitForAsync(() => { @@ -21,7 +18,7 @@ describe('Chat wrapper', () => { beforeEach(() => { fixture = TestBed.createComponent(IgxChatComponent); chatComponent = fixture.componentInstance; - chatElement = (fixture.nativeElement as HTMLElement).querySelector('igc-chat'); + chatElement = getChatElement(fixture); fixture.detectChanges(); }) @@ -43,9 +40,10 @@ describe('Chat wrapper', () => { fixture.detectChanges(); await fixture.whenStable(); - const messageElement = getShadowRoot(chatElement).querySelector('igc-chat-message'); + + const messageElement = getChatMessages(chatElement)[0]; expect(messageElement).toBeDefined(); - expect(getShadowRoot(messageElement).textContent.trim()).toEqual(chatComponent.messages()[0].text); + expect(getChatMessageDOM(messageElement).textContent.trim()).toEqual(chatComponent.messages()[0].text); }); it('correct bindings for draft message', async () => { @@ -54,29 +52,25 @@ describe('Chat wrapper', () => { fixture.detectChanges(); await fixture.whenStable(); - const textarea = getShadowRoot(getShadowRoot(chatElement).querySelector('igc-chat-input')).querySelector('igc-textarea'); + const textarea = getChatInput(chatElement); expect(textarea.value).toEqual(chatComponent.draftMessage().text); }); }); describe('Chat templates', () => { - function getShadowRoot(element: HTMLElement) { - return element.shadowRoot; - } - let fixture: ComponentFixture; - let chatElement: HTMLElement; + let chatElement: IgcChatComponent; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [IgxChatComponent, ChatTemplatesBed] + imports: [IgxChatComponent, IgxChatMessageContextDirective, ChatTemplatesBed] }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(ChatTemplatesBed); fixture.detectChanges(); - chatElement = (fixture.nativeElement as HTMLElement).querySelector('igc-chat'); + chatElement = getChatElement(fixture); }); it('has correct initially bound template', async () => { @@ -87,20 +81,49 @@ describe('Chat templates', () => { // This is so we don't explicitly invoke `viewRef.detectChanges()` inside the returned closure // from the wrapper's `_createTemplateRenderer` call. fixture.detectChanges(); - expect(getShadowRoot(getShadowRoot(chatElement).querySelector('igc-chat-message')).textContent.trim()) + expect(getChatMessageDOM(getChatMessages(chatElement)[0]).textContent.trim()) + .toEqual(`Your message: ${fixture.componentInstance.messages()[0].text}`); + }); +}); + +describe('Chat dynamic templates binding', () => { + let fixture: ComponentFixture; + let chatElement: IgcChatComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [IgxChatComponent, IgxChatMessageContextDirective, ChatDynamicTemplatesBed] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ChatDynamicTemplatesBed); + fixture.detectChanges(); + chatElement = getChatElement(fixture); + }); + + it('supports late binding', async () => { + fixture.componentInstance.bindTemplates(); + fixture.detectChanges(); + + await fixture.whenStable(); + fixture.detectChanges(); + + expect(getChatMessageDOM(getChatMessages(chatElement)[0]).textContent.trim()) .toEqual(`Your message: ${fixture.componentInstance.messages()[0].text}`); }); + }); @Component({ template: ` - +

Your message: {{ message.text }}

`, - imports: [IgxChatComponent] + imports: [IgxChatComponent, IgxChatMessageContextDirective] }) class ChatTemplatesBed { public messages = signal([{ @@ -110,3 +133,45 @@ class ChatTemplatesBed { }]); public messageTemplate = viewChild.required>('message'); } + +@Component({ + template: ` + + +

Your message: {{ message.text }}

+
+ `, + imports: [IgxChatComponent, IgxChatMessageContextDirective] +}) +class ChatDynamicTemplatesBed { + public templates = signal(null); + public messages = signal([{ + id: '1', + sender: 'user', + text: 'Hello world' + }]); + public messageTemplate = viewChild.required>('message'); + + public bindTemplates(): void { + this.templates.set({ + messageContent: this.messageTemplate() + }); + } +} + +function getChatElement(fixture: ComponentFixture): IgcChatComponent { + const nativeElement = fixture.nativeElement as HTMLElement; + return nativeElement.querySelector('igc-chat'); +} + +function getChatInput(chat: IgcChatComponent): IgcTextareaComponent { + return chat.renderRoot.querySelector('igc-chat-input').shadowRoot.querySelector('igc-textarea'); +} + +function getChatMessages(chat: IgcChatComponent): HTMLElement[] { + return Array.from(chat.renderRoot.querySelectorAll('igc-chat-message')); +} + +function getChatMessageDOM(message: HTMLElement) { + return message.shadowRoot; +}