diff --git a/angular.json b/angular.json index d736425e5b0..ac823072f0d 100644 --- a/angular.json +++ b/angular.json @@ -307,8 +307,8 @@ { "type": "bundle", "name": "styles", - "maximumWarning": "500kb", - "maximumError": "550kb" + "maximumWarning": "600kb", + "maximumError": "600kb" }, { "type": "anyComponentStyle", diff --git a/package-lock.json b/package-lock.json index 5849e3ed253..d2a82f586bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,9 +23,9 @@ "@igniteui/material-icons-extended": "^3.1.0", "@lit-labs/ssr-dom-shim": "^1.3.0", "@types/source-map": "0.5.2", - "express": "^5.1.0", + "express": "^5.2.1", "fflate": "^0.8.1", - "igniteui-theming": "^21.0.2", + "igniteui-theming": "^23.2.0", "igniteui-trial-watermark": "^3.1.0", "lodash-es": "^4.17.21", "rxjs": "^7.8.2", @@ -9240,9 +9240,9 @@ } }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -10687,18 +10687,19 @@ "license": "Apache-2.0" }, "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", "dependencies": { "accepts": "^2.0.0", - "body-parser": "^2.2.0", + "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", + "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", @@ -10758,35 +10759,63 @@ } }, "node_modules/express/node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/express/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/express/node_modules/media-typer": { @@ -10844,16 +10873,25 @@ } }, "node_modules/express/node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.6.3", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/express/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -13403,9 +13441,9 @@ } }, "node_modules/igniteui-theming": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/igniteui-theming/-/igniteui-theming-21.0.2.tgz", - "integrity": "sha512-RXs8b3PThVlS1FhLeUT9TlLMcPoNAiwJm/L+jHU7jrwsgZU7gGjipjEbQQRe97AURyTxgXKiC4M8CAuUilWQ2A==", + "version": "23.2.0", + "resolved": "https://registry.npmjs.org/igniteui-theming/-/igniteui-theming-23.2.0.tgz", + "integrity": "sha512-JV3fclrxG72x0ilzqscv1YbhleA3dCzAIZPPPGSaSrnMfnexgursXivl5aJxXpn9683wuzvIVKWiW6oPMq1HHw==", "license": "MIT" }, "node_modules/igniteui-trial-watermark": { diff --git a/package.json b/package.json index 37449a322a4..0538a54c15a 100644 --- a/package.json +++ b/package.json @@ -73,9 +73,9 @@ "@igniteui/material-icons-extended": "^3.1.0", "@lit-labs/ssr-dom-shim": "^1.3.0", "@types/source-map": "0.5.2", - "express": "^5.1.0", + "express": "^5.2.1", "fflate": "^0.8.1", - "igniteui-theming": "^21.0.2", + "igniteui-theming": "^23.2.0", "igniteui-trial-watermark": "^3.1.0", "lodash-es": "^4.17.21", "rxjs": "^7.8.2", diff --git a/projects/bundle-test/src/app/h-grid/h-grid.component.html b/projects/bundle-test/src/app/h-grid/h-grid.component.html new file mode 100644 index 00000000000..ab93d5c978c --- /dev/null +++ b/projects/bundle-test/src/app/h-grid/h-grid.component.html @@ -0,0 +1,28 @@ +
+ Lorem ipsum dolor sit +
++ Lorem ipsum dolor sit +
++ Lorem ipsum dolor sit +
+Brad Stanley has requested to follow you.
+
+ South African entrepreneur Elon Musk is known for founding Tesla Motors and SpaceX, which launched a landmark commercial spacecraft in 2012.
+First slide Content
+ *Second Slide Content
+ *unsafe
+ Hello World
\n'; + + const result = await service.parse(markdown); + expect(result).toBe(expectedHtml); + }); + + it('should parse a code block with shiki highlighting', async () => { + const markdown = '```typescript\nconst x = 5;\n```'; + const result = await service.parse(markdown); + + expect(result).toContain(' {
+ const markdown = '[Infragistics](https://www.infragistics.com)';
+ const expectedLink = '';
+
+ const result = await service.parse(markdown);
+ expect(result).toContain(expectedLink);
+ });
+});
diff --git a/projects/igniteui-angular/chat-extras/src/markdown-service.ts b/projects/igniteui-angular/chat-extras/src/markdown-service.ts
new file mode 100644
index 00000000000..4f2edf2a508
--- /dev/null
+++ b/projects/igniteui-angular/chat-extras/src/markdown-service.ts
@@ -0,0 +1,67 @@
+import { Injectable } from '@angular/core';
+import { Marked } from 'marked';
+import markedShiki from 'marked-shiki';
+import { bundledThemes, createHighlighter } from 'shiki/bundle/web';
+
+
+const DEFAULT_LANGUAGES = ['javascript', 'typescript', 'html', 'css'];
+const DEFAULT_THEMES = {
+ light: 'github-light',
+ dark: 'github-dark'
+};
+
+@Injectable({ providedIn: 'root' })
+export class IgxChatMarkdownService {
+
+ private _instance: Marked;
+ private _isInitialized: Promise;
+
+ private _initializeMarked(): void {
+ this._instance = new Marked({
+ breaks: true,
+ gfm: true,
+ extensions: [
+ {
+ name: 'link',
+ renderer({ href, title, text }) {
+ return `${text}`;
+ }
+ }
+ ]
+ });
+ }
+
+ private async _initializeShiki(): Promise {
+ const highlighter = await createHighlighter({
+ langs: DEFAULT_LANGUAGES,
+ themes: Object.keys(bundledThemes)
+ });
+
+ this._instance.use(
+ markedShiki({
+ highlight(code, lang, _) {
+ try {
+ return highlighter.codeToHtml(code, {
+ lang,
+ themes: DEFAULT_THEMES,
+ });
+
+ } catch {
+ return `${code}
`;
+ }
+ }
+ })
+ );
+ }
+
+
+ constructor() {
+ this._initializeMarked();
+ this._isInitialized = this._initializeShiki();
+ }
+
+ public async parse(text: string): Promise {
+ await this._isInitialized;
+ return await this._instance.parse(text);
+ }
+}
diff --git a/projects/igniteui-angular/chat-extras/src/public_api.ts b/projects/igniteui-angular/chat-extras/src/public_api.ts
new file mode 100644
index 00000000000..de599f08302
--- /dev/null
+++ b/projects/igniteui-angular/chat-extras/src/public_api.ts
@@ -0,0 +1 @@
+export { MarkdownPipe } from './markdown-pipe';
diff --git a/projects/igniteui-angular/chat/index.ts b/projects/igniteui-angular/chat/index.ts
new file mode 100644
index 00000000000..decc72d85bc
--- /dev/null
+++ b/projects/igniteui-angular/chat/index.ts
@@ -0,0 +1 @@
+export * from './src/public_api';
diff --git a/projects/igniteui-angular/chat/ng-package.json b/projects/igniteui-angular/chat/ng-package.json
new file mode 100644
index 00000000000..0967ef424bc
--- /dev/null
+++ b/projects/igniteui-angular/chat/ng-package.json
@@ -0,0 +1 @@
+{}
diff --git a/projects/igniteui-angular/chat/src/chat.component.html b/projects/igniteui-angular/chat/src/chat.component.html
new file mode 100644
index 00000000000..896e36340b4
--- /dev/null
+++ b/projects/igniteui-angular/chat/src/chat.component.html
@@ -0,0 +1,16 @@
+
+
+
diff --git a/projects/igniteui-angular/chat/src/chat.component.ts b/projects/igniteui-angular/chat/src/chat.component.ts
new file mode 100644
index 00000000000..97aa851aeff
--- /dev/null
+++ b/projects/igniteui-angular/chat/src/chat.component.ts
@@ -0,0 +1,329 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ CUSTOM_ELEMENTS_SCHEMA,
+ Directive,
+ effect,
+ inject,
+ input,
+ OnInit,
+ output,
+ signal,
+ TemplateRef,
+ ViewContainerRef,
+ OnDestroy,
+ ViewRef,
+ computed,
+} from '@angular/core';
+import {
+ IgcChatComponent,
+ type IgcChatMessageAttachment,
+ type IgcChatMessage,
+ type IgcChatOptions,
+ type ChatRenderContext,
+ type ChatRenderers,
+ type ChatAttachmentRenderContext,
+ type ChatInputRenderContext,
+ type ChatMessageRenderContext,
+ type IgcChatMessageReaction,
+} from 'igniteui-webcomponents';
+
+type ChatContextUnion =
+ | ChatAttachmentRenderContext
+ | ChatMessageRenderContext
+ | ChatInputRenderContext
+ | ChatRenderContext;
+
+type ChatContextType =
+ T extends ChatAttachmentRenderContext
+ ? IgcChatMessageAttachment
+ : T extends ChatMessageRenderContext
+ ? IgcChatMessage
+ : T extends ChatInputRenderContext
+ ? string
+ : T extends ChatRenderContext
+ ? { instance: IgcChatComponent }
+ : never;
+
+type ExtractChatContext = T extends (ctx: infer R) => any ? R : never;
+
+type ChatTemplatesContextMap = {
+ [K in keyof ChatRenderers]: {
+ $implicit: ChatContextType<
+ ExtractChatContext> & ChatContextUnion
+ >;
+ };
+};
+
+/**
+ * Template references for customizing chat component rendering.
+ * Each property corresponds to a specific part of the chat UI that can be customized.
+ *
+ * @example
+ * ```typescript
+ * templates = {
+ * messageContent: this.customMessageTemplate,
+ * attachment: this.customAttachmentTemplate
+ * }
+ * ```
+ */
+export type IgxChatTemplates = {
+ [K in keyof Omit]?: TemplateRef;
+};
+
+/**
+ * Configuration options for the chat component.
+ */
+export type IgxChatOptions = Omit;
+
+
+/**
+ * Angular wrapper component for the Ignite UI Web Components Chat component.
+ *
+ * This component provides an Angular-friendly interface to the igc-chat web component,
+ * including support for Angular templates, signals, and change detection.
+ *
+ * Uses OnPush change detection strategy for optimal performance. All inputs are signals,
+ * so changes are automatically tracked and propagated to the underlying web component.
+ *
+ * @example
+ * ```typescript
+ *
+ * ```
+ */
+@Component({
+ selector: 'igx-chat',
+ standalone: true,
+ 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>();
+ private _oldTemplates: IgxChatTemplates = {};
+
+ protected readonly _transformedTemplates = signal({});
+
+ protected readonly _mergedOptions = computed(() => {
+ const options = this.options();
+ const transformedTemplates = this._transformedTemplates();
+ return {
+ ...options,
+ renderers: transformedTemplates
+ };
+ });
+
+ //#endregion
+
+ //#region Inputs
+
+ /** Array of chat messages to display */
+ public readonly messages = input([]);
+
+ /** Draft message with text and optional attachments */
+ public readonly draftMessage = input<
+ { text: string; attachments?: IgcChatMessageAttachment[] } | undefined
+ >({ text: '' });
+
+ /** Configuration options for the chat component */
+ public readonly options = input({});
+
+ /** Custom templates for rendering chat elements */
+ public readonly templates = input({});
+
+ //#endregion
+
+ //#region Outputs
+
+ /** Emitted when a new message is created */
+ public readonly messageCreated = output();
+
+ /** Emitted when a user reacts to a message */
+ public readonly messageReact = output();
+
+ /** Emitted when an attachment is clicked */
+ public readonly attachmentClick = output();
+
+ /** Emitted when attachment drag starts */
+ public readonly attachmentDrag = output();
+
+ /** Emitted when attachment is dropped */
+ public readonly attachmentDrop = output();
+
+ /** Emitted when typing indicator state changes */
+ public readonly typingChange = output();
+
+ /** Emitted when the input receives focus */
+ public readonly inputFocus = output();
+
+ /** Emitted when the input loses focus */
+ public readonly inputBlur = output();
+
+ /** Emitted when the input value changes */
+ public readonly inputChange = output();
+
+ //#endregion
+
+ /** @internal */
+ public ngOnInit(): void {
+ IgcChatComponent.register();
+ }
+
+ /** @internal */
+ public ngOnDestroy(): void {
+ for (const viewSet of this._templateViewRefs.values()) {
+ viewSet.forEach(viewRef => viewRef.destroy());
+ }
+ this._templateViewRefs.clear();
+ }
+
+ constructor() {
+ // Templates changed - update transformed templates and viewRefs
+ effect(() => {
+ const templates = this.templates();
+ this._setTemplates(templates ?? {});
+ });
+ }
+
+ private _setTemplates(newTemplates: IgxChatTemplates): void {
+ const templateCopies: ChatRenderers = {};
+ const newTemplateKeys = Object.keys(newTemplates) as Array;
+
+ const oldTemplates = this._oldTemplates;
+ 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);
+ }
+ }
+ }
+
+ this._oldTemplates = {};
+
+ for (const key of newTemplateKeys) {
+ const ref = newTemplates[key];
+ if (ref) {
+ (this._oldTemplates as Record>)[key] = 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: context.value,
+ attachments: context.attachments
+ };
+ } else {
+ angularContext = { $implicit: { instance: context.instance } };
+ }
+
+ const viewRef = this._view.createEmbeddedView(ref, angularContext);
+ viewSet.add(viewRef);
+
+ return viewRef.rootNodes;
+ }
+ }
+}
+
+/**
+ * Context provided to the chat input template.
+ */
+export interface ChatInputContext {
+ /** The current input value */
+ $implicit: string;
+ /** Array of attachments associated with the input */
+ attachments: IgcChatMessageAttachment[];
+}
+
+/**
+ * Directive providing type information for chat message template contexts.
+ * Use this directive on ng-template elements that render chat messages.
+ *
+ * @example
+ * ```html
+ *
+ * {{ message.text }}
+ *
+ * ```
+ */
+@Directive({ selector: '[igxChatMessageContext]', standalone: true })
+export class IgxChatMessageContextDirective {
+
+ public static ngTemplateContextGuard(_: IgxChatMessageContextDirective, ctx: unknown): ctx is { $implicit: IgcChatMessage } {
+ return true;
+ }
+}
+
+/**
+ * Directive providing type information for chat attachment template contexts.
+ * Use this directive on ng-template elements that render message attachments.
+ *
+ * @example
+ * ```html
+ *
+ *
+ *
+ * ```
+ */
+@Directive({ selector: '[igxChatAttachmentContext]', standalone: true })
+export class IgxChatAttachmentContextDirective {
+
+ public static ngTemplateContextGuard(_: IgxChatAttachmentContextDirective, ctx: unknown): ctx is { $implicit: IgcChatMessageAttachment } {
+ return true;
+ }
+}
+
+/**
+ * Directive providing type information for chat input template contexts.
+ * Use this directive on ng-template elements that render the chat input.
+ *
+ * @example
+ * ```html
+ *
+ *
+ *
+ * ```
+ */
+@Directive({ selector: '[igxChatInputContext]', standalone: true })
+export class IgxChatInputContextDirective {
+
+ public static ngTemplateContextGuard(_: IgxChatInputContextDirective, ctx: unknown): ctx is ChatInputContext {
+ return true;
+ }
+}
diff --git a/projects/igniteui-angular/chat/src/chat.spec.ts b/projects/igniteui-angular/chat/src/chat.spec.ts
new file mode 100644
index 00000000000..2977d06f194
--- /dev/null
+++ b/projects/igniteui-angular/chat/src/chat.spec.ts
@@ -0,0 +1,177 @@
+import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'
+import { IgxChatComponent, IgxChatMessageContextDirective, type IgxChatTemplates } from './chat.component'
+import { Component, signal, TemplateRef, viewChild } from '@angular/core';
+import type { IgcChatComponent, IgcChatMessage, IgcTextareaComponent } from 'igniteui-webcomponents';
+
+describe('Chat wrapper', () => {
+
+ let chatComponent: IgxChatComponent;
+ let chatElement: IgcChatComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(waitForAsync(() => {
+ TestBed.configureTestingModule({
+ imports: [IgxChatComponent]
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(IgxChatComponent);
+ chatComponent = fixture.componentInstance;
+ chatElement = getChatElement(fixture);
+ 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 = getChatMessages(chatElement)[0];
+ expect(messageElement).toBeDefined();
+ expect(getChatMessageDOM(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 = getChatInput(chatElement);
+ expect(textarea.value).toEqual(chatComponent.draftMessage().text);
+ });
+});
+
+describe('Chat templates', () => {
+ let fixture: ComponentFixture;
+ let chatElement: IgcChatComponent;
+
+ beforeEach(waitForAsync(() => {
+ TestBed.configureTestingModule({
+ imports: [IgxChatComponent, IgxChatMessageContextDirective, ChatTemplatesBed]
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ChatTemplatesBed);
+ fixture.detectChanges();
+ chatElement = getChatElement(fixture);
+ });
+
+ 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(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, IgxChatMessageContextDirective]
+})
+class ChatTemplatesBed {
+ public messages = signal([{
+ id: '1',
+ sender: 'user',
+ text: 'Hello world'
+ }]);
+ 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;
+}
diff --git a/projects/igniteui-angular/chat/src/public_api.ts b/projects/igniteui-angular/chat/src/public_api.ts
new file mode 100644
index 00000000000..eca793fd7b9
--- /dev/null
+++ b/projects/igniteui-angular/chat/src/public_api.ts
@@ -0,0 +1 @@
+export * from './chat.component';
diff --git a/projects/igniteui-angular/checkbox/README.md b/projects/igniteui-angular/checkbox/README.md
new file mode 100644
index 00000000000..de933d26899
--- /dev/null
+++ b/projects/igniteui-angular/checkbox/README.md
@@ -0,0 +1,89 @@
+# igx-checkbox
+
+`igx-checkbox` is a selection component that allows users to make a binary choice for a certain condition. It behaves similar to the native browser checkbox.
+A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/checkbox.html)
+
+# Usage
+
+Basic usage of `igx-checkbox`
+
+```html
+
+ -
+
+ {{ task.description }}
+
+
+
+```
+
+You can easily use it within forms with `[(ngModel)]`
+
+```html
+
+```
+
+### Checkbox Label
+
+The checkbox label is set to anything passed between the opening and closing tags of the `` component.
+
+The position of the label can be set to either `before` or `after`(default) the actual checkbox using the `labelPosition` input property. For instance, to set the label position ___before___ the checkbox:
+
+```html
+Label
+```
+
+### Indeterminate State
+
+The checkbox component supports an indeterminate state, which behaves the same as the native [indeterminate state](https://developer.mozilla.org/en-US/docs/Web/CSS/:indeterminate) of an input of type checkbox.
+To set the indeterminate state for an `igx-checkbox`, do:
+
+```html
+Label
+```
+
+### Ripple Touch Feedback
+
+The `igx-checkbox` is styled according to the Google's Material spec, and provides a ripple effect around the checkbox when the checkbox is clicked/tapped.
+To disable the ripple effect, do:
+
+```html
+
+```
+
+## API
+
+# API Summary
+| Name | Type | Description |
+|:----------|:-------------:|:------|
+| `@Input()` id | string | The unique `id` attribute to be used for the checkbox. If you do not provide a value, it will be auto-generated. |
+| `@Input()` labelId | string | The unique `id` attribute to be used for the checkbox label. If you do not provide a value, it will be auto-generated. |
+| `@Input()` name | string | The `name` attribute to be used for the checkbox. |
+| `@Input()` value | any | The value to be set for the checkbox. |
+| `@Input()` tabindex | number | Specifies the tabbing order of the checkbox. |
+| `@Input()` checked | boolean | Specifies the checked state of the checkbox. |
+| `@Input()` indeterminate | boolean | Specifies the indeterminate state of the checkbox. |
+| `@Input()` required | boolean | Specifies the required state of the checkbox. |
+| `@Input()` disabled | boolean | Specifies the disabled state of the checkbox. |
+| `@Input()` readonly | boolean | Specifies the readonly state of the checkbox. |
+| `@Input()` disableRipple | boolean | Specifies whether the ripple effect should be disabled for the checkbox. |
+| `@Input()` disableTransitions | boolean | Specifies whether CSS transitions should be disabled for the checkbox. |
+| `@Input()` labelPosition | string `|` enum LabelPosition | Specifies the position of the text label relative to the checkbox element. |
+| `@Input("aria-labelledby")` ariaLabelledBy | string | Specify an external element by id to be used as label for the checkbox. |
+| `@Output()` change | EventEmitter | Emitted when the checkbox checked value changes. |
+
+### Methods
+
+| toggle |
+|:----------|
+| Toggles the checked state of the checkbox. |
diff --git a/projects/igniteui-angular/checkbox/index.ts b/projects/igniteui-angular/checkbox/index.ts
new file mode 100644
index 00000000000..decc72d85bc
--- /dev/null
+++ b/projects/igniteui-angular/checkbox/index.ts
@@ -0,0 +1 @@
+export * from './src/public_api';
diff --git a/projects/igniteui-angular/checkbox/ng-package.json b/projects/igniteui-angular/checkbox/ng-package.json
new file mode 100644
index 00000000000..2c63c085104
--- /dev/null
+++ b/projects/igniteui-angular/checkbox/ng-package.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.html b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.html
new file mode 100644
index 00000000000..9bab0879680
--- /dev/null
+++ b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.html
@@ -0,0 +1,47 @@
+
+
+
+
+ @if (theme === 'indigo') {
+
+ } @else {
+
+ }
+
+
+
+
+
+
+
+
diff --git a/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.spec.ts b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.spec.ts
new file mode 100644
index 00000000000..1c0cd7e15f7
--- /dev/null
+++ b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.spec.ts
@@ -0,0 +1,532 @@
+import { Component, ViewChild, inject } from '@angular/core';
+import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing';
+import { UntypedFormBuilder, FormsModule, ReactiveFormsModule, Validators, NgForm } from '@angular/forms';
+import { By } from '@angular/platform-browser';
+import { IgxCheckboxComponent } from './checkbox.component';
+
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+
+describe('IgxCheckbox', () => {
+ beforeEach(waitForAsync(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ NoopAnimationsModule,
+ InitCheckboxComponent,
+ CheckboxSimpleComponent,
+ CheckboxReadonlyComponent,
+ CheckboxIndeterminateComponent,
+ CheckboxRequiredComponent,
+ CheckboxExternalLabelComponent,
+ CheckboxInvisibleLabelComponent,
+ CheckboxDisabledTransitionsComponent,
+ CheckboxFormComponent,
+ CheckboxFormGroupComponent,
+ IgxCheckboxComponent
+ ]
+ }).compileComponents();
+ }));
+
+ it('Initializes a checkbox', () => {
+ const fixture = TestBed.createComponent(InitCheckboxComponent);
+ fixture.detectChanges();
+
+ const checkbox = fixture.componentInstance.cb;
+ const nativeCheckbox = checkbox.nativeInput.nativeElement;
+ const nativeLabel = checkbox.nativeLabel.nativeElement;
+ const placeholderLabel = fixture.debugElement.query(By.css('.igx-checkbox__label')).nativeElement;
+
+ expect(nativeCheckbox).toBeTruthy();
+ expect(nativeCheckbox.id).toContain('igx-checkbox-');
+ expect(nativeCheckbox.getAttribute('aria-label')).toEqual(null);
+ expect(nativeCheckbox.getAttribute('aria-labelledby')).toContain('igx-checkbox-');
+
+ expect(nativeLabel).toBeTruthy();
+ // No longer have a for attribute to not propagate clicks to the native checkbox
+ // expect(nativeLabel.getAttribute('for')).toEqual('igx-checkbox-0-input');
+
+ expect(placeholderLabel.textContent.trim()).toEqual('Init');
+ expect(placeholderLabel.classList).toContain('igx-checkbox__label');
+ expect(placeholderLabel.getAttribute('id')).toContain('igx-checkbox-');
+
+ // When aria-label is present, aria-labeledby shouldn't be
+ checkbox.ariaLabel = 'New Label';
+ fixture.detectChanges();
+ expect(nativeCheckbox.getAttribute('aria-labelledby')).toEqual(null);
+ expect(nativeCheckbox.getAttribute('aria-label')).toMatch('New Label');
+ });
+
+ it('Initializes with ngModel', fakeAsync(() => {
+ const fixture = TestBed.createComponent(CheckboxSimpleComponent);
+ fixture.detectChanges();
+
+ const testInstance = fixture.componentInstance;
+ const checkboxInstance = testInstance.cb;
+ const nativeCheckbox = checkboxInstance.nativeInput.nativeElement;
+
+ fixture.detectChanges();
+
+ expect(nativeCheckbox.checked).toBe(false);
+ expect(checkboxInstance.checked).toBe(null);
+
+ testInstance.subscribed = true;
+ checkboxInstance.name = 'my-checkbox';
+ // One change detection cycle for updating our checkbox
+ fixture.detectChanges();
+ tick();
+ expect(checkboxInstance.checked).toBe(true);
+
+ // Now one more change detection cycle to update the native checkbox
+ fixture.detectChanges();
+ tick();
+ expect(nativeCheckbox.checked).toBe(true);
+ expect(checkboxInstance.name).toEqual('my-checkbox');
+ }));
+
+ it('Initializes with form group', () => {
+ const fixture = TestBed.createComponent(CheckboxFormGroupComponent);
+ fixture.detectChanges();
+
+ const testInstance = fixture.componentInstance;
+ const checkboxInstance = testInstance.cb;
+ const form = testInstance.myForm;
+
+ form.setValue({ checkbox: true });
+ expect(checkboxInstance.checked).toBe(true);
+
+ form.reset();
+
+ expect(checkboxInstance.checked).toBe(null);
+ });
+
+ it('Initializes with external label', () => {
+ const fixture = TestBed.createComponent(CheckboxExternalLabelComponent);
+ const checkboxInstance = fixture.componentInstance.cb;
+ const nativeCheckbox = checkboxInstance.nativeInput.nativeElement;
+ const externalLabel = fixture.debugElement.query(By.css('#my-label')).nativeElement;
+ fixture.detectChanges();
+
+ expect(nativeCheckbox.getAttribute('aria-labelledby')).toMatch(externalLabel.getAttribute('id'));
+ expect(externalLabel.textContent).toMatch(fixture.componentInstance.label);
+ });
+
+ it('Initializes with invisible label', () => {
+ const fixture = TestBed.createComponent(CheckboxInvisibleLabelComponent);
+ const checkboxInstance = fixture.componentInstance.cb;
+ const nativeCheckbox = checkboxInstance.nativeInput.nativeElement;
+ fixture.detectChanges();
+
+ expect(nativeCheckbox.getAttribute('aria-label')).toMatch(fixture.componentInstance.label);
+ });
+
+ it('Positions label before and after checkbox', () => {
+ const fixture = TestBed.createComponent(CheckboxSimpleComponent);
+ const checkboxInstance = fixture.componentInstance.cb;
+ const placeholderLabel = checkboxInstance.placeholderLabel.nativeElement;
+ const labelStyles = window.getComputedStyle(placeholderLabel);
+ fixture.detectChanges();
+
+ expect(labelStyles.order).toEqual('0');
+
+ checkboxInstance.labelPosition = 'before';
+ fixture.detectChanges();
+
+ expect(labelStyles.order).toEqual('-1');
+ });
+
+ it('Indeterminate state', fakeAsync(() => {
+ const fixture = TestBed.createComponent(CheckboxIndeterminateComponent);
+ const testInstance = fixture.componentInstance;
+ const checkboxInstance = testInstance.cb;
+ const nativeCheckbox = checkboxInstance.nativeInput.nativeElement;
+ const nativeLabel = checkboxInstance.nativeLabel.nativeElement;
+
+ // Before any changes indeterminate should be true
+ fixture.detectChanges();
+ expect(checkboxInstance.indeterminate).toBe(true);
+ expect(nativeCheckbox.indeterminate).toBe(true);
+
+ testInstance.subscribed = true;
+
+ fixture.detectChanges();
+ tick();
+ // First change detection should update our checkbox state and API call should not change indeterminate
+ expect(checkboxInstance.checked).toBe(true);
+ expect(checkboxInstance.indeterminate).toBe(true);
+
+ // Second change detection should update native checkbox state but indeterminate should not change
+ fixture.detectChanges();
+ tick();
+ expect(nativeCheckbox.indeterminate).toBe(true);
+ expect(nativeCheckbox.checked).toBe(true);
+
+ // Should not change the state
+ nativeCheckbox.dispatchEvent(new Event('change'));
+ fixture.detectChanges();
+
+ expect(nativeCheckbox.indeterminate).toBe(true);
+ expect(checkboxInstance.checked).toBe(true);
+ expect(nativeCheckbox.checked).toBe(true);
+
+ // Should update the state on click
+ nativeLabel.click();
+ fixture.detectChanges();
+
+ expect(nativeCheckbox.indeterminate).toBe(false);
+ expect(checkboxInstance.checked).toBe(false);
+ expect(nativeCheckbox.checked).toBe(false);
+
+ // Should update the state again on click
+ nativeLabel.click();
+ fixture.detectChanges();
+
+ expect(nativeCheckbox.indeterminate).toBe(false);
+ expect(checkboxInstance.checked).toBe(true);
+ expect(nativeCheckbox.checked).toBe(true);
+
+ // Should be able to set indeterminate again
+ checkboxInstance.indeterminate = true;
+ fixture.detectChanges();
+
+ expect(nativeCheckbox.indeterminate).toBe(true);
+ expect(checkboxInstance.checked).toBe(true);
+ expect(nativeCheckbox.checked).toBe(true);
+ }));
+
+ it('Disabled state', () => {
+ const fixture = TestBed.createComponent(IgxCheckboxComponent);
+
+ const checkboxInstance = fixture.componentInstance;
+ // For test fixture destroy
+ checkboxInstance.id = "root1";
+ checkboxInstance.disabled = true;
+ const nativeCheckbox = checkboxInstance.nativeInput.nativeElement as HTMLInputElement;
+ const nativeLabel = checkboxInstance.nativeLabel.nativeElement as HTMLLabelElement;
+ const placeholderLabel = checkboxInstance.placeholderLabel.nativeElement;
+ fixture.detectChanges();
+
+ expect(checkboxInstance.disabled).toBe(true);
+ expect(nativeCheckbox.disabled).toBe(true);
+
+ nativeCheckbox.click();
+ nativeLabel.click();
+ placeholderLabel.click();
+ fixture.detectChanges();
+
+ // Should not update
+ expect(checkboxInstance.checked).toBe(false);
+ });
+
+ it('Readonly state', () => {
+ const fixture = TestBed.createComponent(CheckboxReadonlyComponent);
+ const testInstance = fixture.componentInstance;
+ const checkboxInstance = testInstance.cb;
+ const nativeCheckbox = checkboxInstance.nativeInput.nativeElement;
+ const nativeLabel = checkboxInstance.nativeLabel.nativeElement;
+ const placeholderLabel = checkboxInstance.placeholderLabel.nativeElement;
+ fixture.detectChanges();
+ expect(checkboxInstance.readonly).toBe(true);
+ expect(testInstance.subscribed).toBe(false);
+
+ nativeCheckbox.dispatchEvent(new Event('change'));
+ fixture.detectChanges();
+ // Should not update
+ expect(testInstance.subscribed).toBe(false);
+
+ nativeLabel.click();
+ fixture.detectChanges();
+ // Should not update
+ expect(testInstance.subscribed).toBe(false);
+
+ placeholderLabel.click();
+ fixture.detectChanges();
+ // Should not update
+ expect(testInstance.subscribed).toBe(false);
+
+ nativeCheckbox.click();
+ fixture.detectChanges();
+ // Should not update
+ expect(testInstance.subscribed).toBe(false);
+ expect(checkboxInstance.indeterminate).toBe(true);
+ });
+
+ it('Should be able to enable/disable CSS transitions', () => {
+ const fixture = TestBed.createComponent(CheckboxDisabledTransitionsComponent);
+ const testInstance = fixture.componentInstance;
+ const checkboxInstance = testInstance.cb;
+ const checkboxHost = fixture.debugElement.query(By.css('igx-checkbox')).nativeElement;
+ fixture.detectChanges();
+
+ expect(checkboxInstance.disableTransitions).toBe(true);
+ expect(checkboxHost.classList).toContain('igx-checkbox--plain');
+
+ testInstance.cb.disableTransitions = false;
+ fixture.detectChanges();
+ expect(checkboxHost.classList).not.toContain('igx-checkbox--plain');
+ });
+
+ it('Required state', () => {
+ const fixture = TestBed.createComponent(CheckboxRequiredComponent);
+ const testInstance = fixture.componentInstance;
+ const checkboxInstance = testInstance.cb;
+ const nativeCheckbox = checkboxInstance.nativeInput.nativeElement;
+ fixture.detectChanges();
+
+ expect(checkboxInstance.required).toBe(true);
+ expect(nativeCheckbox.required).toBeTruthy();
+
+ checkboxInstance.required = false;
+ nativeCheckbox.required = false;
+ fixture.detectChanges();
+
+ expect(checkboxInstance.required).toBe(false);
+ expect(nativeCheckbox.required).toBe(false);
+ });
+
+ it('Event handling', () => {
+ const fixture = TestBed.createComponent(CheckboxSimpleComponent);
+ const testInstance = fixture.componentInstance;
+ const checkboxInstance = testInstance.cb;
+ const cbxEl = fixture.debugElement.query(By.directive(IgxCheckboxComponent)).nativeElement;
+ const nativeCheckbox = checkboxInstance.nativeInput.nativeElement;
+ const nativeLabel = checkboxInstance.nativeLabel.nativeElement;
+ const placeholderLabel = checkboxInstance.placeholderLabel.nativeElement;
+
+ fixture.detectChanges();
+ expect(checkboxInstance.focused).toBe(false);
+
+ cbxEl.dispatchEvent(new KeyboardEvent('keyup'));
+ fixture.detectChanges();
+ expect(checkboxInstance.focused).toBe(true);
+
+ nativeCheckbox.dispatchEvent(new Event('blur'));
+ fixture.detectChanges();
+ expect(checkboxInstance.focused).toBe(false);
+
+ nativeLabel.click();
+ fixture.detectChanges();
+
+ expect(testInstance.changeEventCalled).toBe(true);
+ expect(testInstance.subscribed).toBe(true);
+ expect(testInstance.clickCounter).toEqual(1);
+
+ placeholderLabel.click();
+ fixture.detectChanges();
+
+ expect(testInstance.changeEventCalled).toBe(true);
+ expect(testInstance.subscribed).toBe(false);
+ expect(testInstance.clickCounter).toEqual(2);
+ });
+
+ it('Should update style when required checkbox\'s value is set.', () => {
+ const fixture = TestBed.createComponent(CheckboxRequiredComponent);
+ fixture.detectChanges();
+
+ const checkboxInstance = fixture.componentInstance.cb;
+ const domCheckbox = fixture.debugElement.query(By.css('igx-checkbox')).nativeElement;
+
+ expect(domCheckbox.classList.contains('igx-checkbox--invalid')).toBe(false);
+ expect(checkboxInstance.invalid).toBe(false);
+ expect(checkboxInstance.checked).toBe(false);
+ expect(checkboxInstance.required).toBe(true);
+
+ dispatchCbEvent('keyup', domCheckbox, fixture);
+ expect(domCheckbox.classList.contains('igx-checkbox--focused')).toBe(true);
+ dispatchCbEvent('blur', domCheckbox, fixture);
+
+ expect(checkboxInstance.invalid).toBe(true);
+ expect(domCheckbox.classList.contains('igx-checkbox--invalid')).toBe(true);
+
+ dispatchCbEvent('keyup', domCheckbox, fixture);
+ expect(domCheckbox.classList.contains('igx-checkbox--focused')).toBe(true);
+ dispatchCbEvent('click', domCheckbox, fixture);
+
+ expect(domCheckbox.classList.contains('igx-checkbox--checked')).toBe(true);
+ expect(checkboxInstance.checked).toBe(true);
+ expect(checkboxInstance.invalid).toBe(false);
+ expect(domCheckbox.classList.contains('igx-checkbox--invalid')).toBe(false);
+
+ dispatchCbEvent('click', domCheckbox, fixture);
+ dispatchCbEvent('keyup', domCheckbox, fixture);
+ expect(domCheckbox.classList.contains('igx-checkbox--focused')).toBe(true);
+ dispatchCbEvent('blur', domCheckbox, fixture);
+
+ expect(checkboxInstance.checked).toBe(false);
+ expect(checkboxInstance.invalid).toBe(true);
+ expect(domCheckbox.classList.contains('igx-checkbox--invalid')).toBe(true);
+ });
+
+ it('Should work properly with ngModel', fakeAsync(() => {
+ const fixture = TestBed.createComponent(CheckboxFormComponent);
+ fixture.detectChanges();
+ tick();
+
+ const checkbox = fixture.componentInstance.checkbox;
+ expect(checkbox.invalid).toEqual(false);
+
+ checkbox.onBlur();
+ expect(checkbox.invalid).toEqual(true);
+
+ fixture.componentInstance.ngForm.resetForm();
+ tick();
+ expect(checkbox.invalid).toEqual(false);
+ }));
+
+ it('Should work properly with reactive forms validation.', () => {
+ const fixture = TestBed.createComponent(CheckboxFormGroupComponent);
+ fixture.detectChanges();
+
+ const checkbox = fixture.componentInstance.cb;
+ const cbxEl = fixture.debugElement.query(By.directive(IgxCheckboxComponent)).nativeElement;
+ expect(checkbox.required).toBe(true);
+ expect(checkbox.invalid).toBe(false);
+ expect(cbxEl.classList.contains('igx-checkbox--invalid')).toBe(false);
+ expect(checkbox.nativeElement.getAttribute('aria-required')).toEqual('true');
+ expect(checkbox.nativeElement.getAttribute('aria-invalid')).toEqual('false');
+
+ dispatchCbEvent('keyup', cbxEl, fixture);
+ expect(checkbox.focused).toBe(true);
+ dispatchCbEvent('blur', cbxEl, fixture);
+
+ expect(cbxEl.classList.contains('igx-checkbox--invalid')).toBe(true);
+ expect(checkbox.invalid).toBe(true);
+ expect(checkbox.nativeElement.getAttribute('aria-invalid')).toEqual('true');
+
+ checkbox.checked = true;
+ fixture.detectChanges();
+
+ expect(cbxEl.classList.contains('igx-checkbox--invalid')).toBe(false);
+ expect(checkbox.invalid).toBe(false);
+ expect(checkbox.nativeElement.getAttribute('aria-invalid')).toEqual('false');
+ });
+
+ describe('EditorProvider', () => {
+ it('Should return correct edit element', () => {
+ const fixture = TestBed.createComponent(CheckboxSimpleComponent);
+ fixture.detectChanges();
+
+ const instance = fixture.componentInstance.cb;
+ const editElement = fixture.debugElement.query(By.css('.igx-checkbox__input')).nativeElement;
+
+ expect(instance.getEditElement()).toBe(editElement);
+ });
+ });
+});
+
+@Component({
+ template: `Init `,
+ imports: [IgxCheckboxComponent]
+})
+class InitCheckboxComponent {
+ @ViewChild('cb', { static: true }) public cb: IgxCheckboxComponent;
+}
+
+@Component({
+ template: `Simple `,
+ imports: [IgxCheckboxComponent, FormsModule]
+})
+class CheckboxSimpleComponent {
+ @ViewChild('cb', { static: true }) public cb: IgxCheckboxComponent;
+ public changeEventCalled = false;
+ public subscribed = false;
+ public clickCounter = 0;
+ public onChange() {
+ this.changeEventCalled = true;
+ }
+ public onClick() {
+ this.clickCounter++;
+ }
+}
+@Component({
+ template: `Indeterminate `,
+ imports: [IgxCheckboxComponent, FormsModule]
+})
+class CheckboxIndeterminateComponent {
+ @ViewChild('cb', { static: true }) public cb: IgxCheckboxComponent;
+
+ public subscribed = false;
+}
+
+@Component({
+ template: `Required `,
+ imports: [IgxCheckboxComponent]
+})
+class CheckboxRequiredComponent {
+ @ViewChild('cb', { static: true }) public cb: IgxCheckboxComponent;
+}
+
+@Component({
+ template: `Readonly `,
+ imports: [IgxCheckboxComponent, FormsModule]
+})
+class CheckboxReadonlyComponent {
+ @ViewChild('cb', { static: true }) public cb: IgxCheckboxComponent;
+
+ public subscribed = false;
+}
+
+@Component({
+ template: `{{label}}
+ `,
+ imports: [IgxCheckboxComponent]
+})
+class CheckboxExternalLabelComponent {
+ @ViewChild('cb', { static: true }) public cb: IgxCheckboxComponent;
+ public label = 'My Label';
+}
+
+@Component({
+ template: ` `,
+ imports: [IgxCheckboxComponent]
+})
+class CheckboxInvisibleLabelComponent {
+ @ViewChild('cb', { static: true }) public cb: IgxCheckboxComponent;
+ public label = 'Invisible Label';
+}
+
+@Component({
+ template: ` `,
+ imports: [IgxCheckboxComponent]
+})
+class CheckboxDisabledTransitionsComponent {
+ @ViewChild('cb', { static: true }) public cb: IgxCheckboxComponent;
+}
+
+@Component({
+ template: ``,
+ imports: [IgxCheckboxComponent, ReactiveFormsModule]
+})
+class CheckboxFormGroupComponent {
+ private fb = inject(UntypedFormBuilder);
+
+ @ViewChild('cb', { static: true }) public cb: IgxCheckboxComponent;
+
+ public myForm = this.fb.group({ checkbox: ['', Validators.required] });
+}
+@Component({
+ template: `
+
+ `,
+ imports: [IgxCheckboxComponent, FormsModule]
+})
+class CheckboxFormComponent {
+ @ViewChild('checkbox', { read: IgxCheckboxComponent, static: true })
+ public checkbox: IgxCheckboxComponent;
+ @ViewChild(NgForm, { static: true })
+ public ngForm: NgForm;
+ public subscribed: string;
+}
+
+const dispatchCbEvent = (eventName, cbNativeElement, fixture) => {
+ cbNativeElement.dispatchEvent(new Event(eventName));
+ fixture.detectChanges();
+};
diff --git a/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.ts b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.ts
new file mode 100644
index 00000000000..f79b9362451
--- /dev/null
+++ b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.ts
@@ -0,0 +1,227 @@
+import {
+ Component,
+ HostBinding,
+ Input,
+ AfterViewInit,
+ booleanAttribute,
+} from '@angular/core';
+import { CheckboxBaseDirective, IgxRippleDirective } from 'igniteui-angular/directives';
+import { ControlValueAccessor } from '@angular/forms';
+import { EditorProvider, EDITOR_PROVIDER } from 'igniteui-angular/core';
+
+/**
+ * Allows users to make a binary choice for a certain condition.
+ *
+ * @igxModule IgxCheckboxModule
+ *
+ * @igxTheme igx-checkbox-theme
+ *
+ * @igxKeywords checkbox, label
+ *
+ * @igxGroup Data entry and display
+ *
+ * @remarks
+ * The Ignite UI Checkbox is a selection control that allows users to make a binary choice for a certain condition.It behaves similarly
+ * to the native browser checkbox.
+ *
+ * @example
+ * ```html
+ *
+ * simple checkbox
+ *
+ * ```
+ */
+@Component({
+ selector: 'igx-checkbox',
+ providers: [
+ {
+ provide: EDITOR_PROVIDER,
+ useExisting: IgxCheckboxComponent,
+ multi: true,
+ },
+ ],
+ preserveWhitespaces: false,
+ templateUrl: 'checkbox.component.html',
+ imports: [IgxRippleDirective],
+})
+export class IgxCheckboxComponent
+ extends CheckboxBaseDirective
+ implements AfterViewInit, ControlValueAccessor, EditorProvider {
+ /**
+ * Returns the class of the checkbox component.
+ *
+ * @example
+ * ```typescript
+ * let class = this.checkbox.cssClass;
+ * ```
+ */
+ @HostBinding('class.igx-checkbox')
+ public override cssClass = 'igx-checkbox';
+
+ /**
+ * Returns if the component is of type `material`.
+ *
+ * @example
+ * ```typescript
+ * let checkbox = this.checkbox.material;
+ * ```
+ */
+ @HostBinding('class.igx-checkbox--material')
+ protected get material() {
+ return this.theme === 'material';
+ }
+
+ /**
+ * Returns if the component is of type `indigo`.
+ *
+ * @example
+ * ```typescript
+ * let checkbox = this.checkbox.indigo;
+ * ```
+ */
+ @HostBinding('class.igx-checkbox--indigo')
+ protected get indigo() {
+ return this.theme === 'indigo';
+ }
+
+ /**
+ * Returns if the component is of type `bootstrap`.
+ *
+ * @example
+ * ```typescript
+ * let checkbox = this.checkbox.bootstrap;
+ * ```
+ */
+ @HostBinding('class.igx-checkbox--bootstrap')
+ protected get bootstrap() {
+ return this.theme === 'bootstrap';
+ }
+
+ /**
+ * Returns if the component is of type `fluent`.
+ *
+ * @example
+ * ```typescript
+ * let checkbox = this.checkbox.fluent;
+ * ```
+ */
+ @HostBinding('class.igx-checkbox--fluent')
+ protected get fluent() {
+ return this.theme === 'fluent';
+ }
+
+ /**
+ * Sets/gets whether the checkbox component is on focus.
+ * Default value is `false`.
+ *
+ * @example
+ * ```typescript
+ * this.checkbox.focused = true;
+ * ```
+ * ```typescript
+ * let isFocused = this.checkbox.focused;
+ * ```
+ */
+ @HostBinding('class.igx-checkbox--focused')
+ public override focused = false;
+
+ /**
+ * Sets/gets the checkbox indeterminate visual state.
+ * Default value is `false`;
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ * ```typescript
+ * let isIndeterminate = this.checkbox.indeterminate;
+ * ```
+ */
+ @HostBinding('class.igx-checkbox--indeterminate')
+ @Input({ transform: booleanAttribute })
+ public override indeterminate = false;
+
+ /**
+ * Sets/gets whether the checkbox is checked.
+ * Default value is `false`.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ * ```typescript
+ * let isChecked = this.checkbox.checked;
+ * ```
+ */
+ @HostBinding('class.igx-checkbox--checked')
+ @Input({ transform: booleanAttribute })
+ public override set checked(value: boolean) {
+ super.checked = value;
+ }
+ public override get checked() {
+ return super.checked;
+ }
+
+ /**
+ * Sets/gets whether the checkbox is disabled.
+ * Default value is `false`.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ * ```typescript
+ * let isDisabled = this.checkbox.disabled;
+ * ```
+ */
+ @HostBinding('class.igx-checkbox--disabled')
+ @Input({ transform: booleanAttribute })
+ public override disabled = false;
+
+ /**
+ * Sets/gets whether the checkbox is invalid.
+ * Default value is `false`.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ * ```typescript
+ * let isInvalid = this.checkbox.invalid;
+ * ```
+ */
+ @HostBinding('class.igx-checkbox--invalid')
+ @Input({ transform: booleanAttribute })
+ public override invalid = false;
+
+ /**
+ * Sets/gets whether the checkbox is readonly.
+ * Default value is `false`.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ * ```typescript
+ * let readonly = this.checkbox.readonly;
+ * ```
+ */
+ @Input({ transform: booleanAttribute })
+ public override readonly = false;
+
+ /**
+ * Sets/gets whether the checkbox should disable all css transitions.
+ * Default value is `false`.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ * ```typescript
+ * let disableTransitions = this.checkbox.disableTransitions;
+ * ```
+ */
+ @HostBinding('class.igx-checkbox--plain')
+ @Input({ transform: booleanAttribute })
+ public disableTransitions = false;
+}
diff --git a/projects/igniteui-angular/checkbox/src/checkbox/checkbox.module.ts b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.module.ts
new file mode 100644
index 00000000000..5ad2cd5dbe9
--- /dev/null
+++ b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.module.ts
@@ -0,0 +1,12 @@
+import { NgModule } from '@angular/core';
+import { IgxCheckboxComponent } from './checkbox.component';
+
+/**
+ * @hidden
+ * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components
+ */
+@NgModule({
+ imports: [IgxCheckboxComponent],
+ exports: [IgxCheckboxComponent]
+})
+export class IgxCheckboxModule {}
diff --git a/projects/igniteui-angular/checkbox/src/checkbox/public_api.ts b/projects/igniteui-angular/checkbox/src/checkbox/public_api.ts
new file mode 100644
index 00000000000..ea6b061501f
--- /dev/null
+++ b/projects/igniteui-angular/checkbox/src/checkbox/public_api.ts
@@ -0,0 +1,2 @@
+export { LabelPosition, type IChangeCheckboxEventArgs } from "igniteui-angular/directives";
+export * from "./checkbox.component";
diff --git a/projects/igniteui-angular/checkbox/src/public_api.ts b/projects/igniteui-angular/checkbox/src/public_api.ts
new file mode 100644
index 00000000000..c646179661f
--- /dev/null
+++ b/projects/igniteui-angular/checkbox/src/public_api.ts
@@ -0,0 +1,2 @@
+export * from './checkbox/public_api';
+export * from './checkbox/checkbox.module';
diff --git a/projects/igniteui-angular/chips/README.md b/projects/igniteui-angular/chips/README.md
new file mode 100644
index 00000000000..f1f1c7fb767
--- /dev/null
+++ b/projects/igniteui-angular/chips/README.md
@@ -0,0 +1,192 @@
+# igxChip Component
+
+The **igxChip** is a compact visual component that displays information in an obround. A chip can be templated, deleted and selected. Multiple chips can be reordered and visually connected to each other. Chips reside in a container called chips area which is responsible for managing the interactions between the chips.
+
+#### Initializing Chips
+
+The `IgxChipComponent` is the main class for a chip element and the `IgxChipsAreaComponent` is the main class for the chip area. The chip area is used for handling more complex scenarios that require interaction between chips (dragging, selection, navigation, etc.). The `IgxChipComponent` has an `id` input so that the different chips can be easily distinguished. If `id` is not provided it will be automatically generated.
+
+Example of using `igxChip` with `igxChipArea`:
+
+```html
+
+
+ {{chip.text}}
+
+
+```
+
+### Features
+
+#### Selection
+
+Selection can be enabled by setting an input called `selectable`. The selecting is done either by clicking on the chip itself or by using the `Tab` key to focus the chip and then pressing the `Space` key. If a chip is already selected it can be deselected by pressing the `Space` key again while the chip is focused or by clicking on it.
+
+An event `onSelection` is fired when the selection state of the `igxChip` changes. It provides the new `selected` value so you can get the new state and the original event in `originalEvent` that triggered this selection change. If this is not done through user interaction but instead is done by setting the `selected` property programmatically the `originalEvent` argument has value `null`.
+
+Also by default an icon is shown indicating that the chip is being selected. It is fully customizable and can be done through the `selectIcon` input. It accepts values of type `TemplateRef` and overrides the default icon while retaining the same functionality.
+
+Example of customizing the select icon:
+
+```html
+
+
+ {{chip.text}}
+
+
+
+ done_outline
+
+```
+
+#### Removing
+
+Removing can be enabled by setting the `removable` input to `true`. When enabled a remove button is rendered at the end of the chip. When the end-users performs any interaction like clicking on the remove button or pressing the `Delete` key while the chip is focused the `remove` event is emitted.
+
+By default the chip does not remove itself from the template when the user wants to delete a chip. This needs to be handled manually using the `remove` event.
+
+If you need to customize the remove icon use the `removeIcon` input. It takes a value of type `TemplateRef` and renders it instead of the default remove icon. This means that you can customize the remove button in any way while all the handling of it is still handled by the chip itself.
+
+Example of handling chip removing and custom remove icon:
+```html
+
+
+ {{chip.text}}
+
+
+
+ delete
+
+```
+
+```ts
+public chipRemoved(event) {
+ this.chipList = this.chipList.filter((item) => {
+ return item.id !== event.owner.id;
+ });
+ this.cdr.detectChanges();
+}
+```
+
+#### Moving/Dragging
+
+The chip can be dragged by the end-user in order to change its position. The moving/dragging is disabled by default, but can be enabled by setting an input `draggable`. The actual moving of the chip in the template has to be handled manually by the developer.
+
+```html
+
+
+ {{chip.text}}
+
+
+```
+
+```ts
+public ngOnInit() {
+ chipArea.forEach((chip) => {
+ chip.draggable = true;
+ });
+}
+
+public chipsOrderChanged(event) {
+ const newChipList = [];
+ for (const chip of event.chipsArray) {
+ const chipItem = this.chipList.filter((item) => {
+ return item.id === chip.id;
+ })[0];
+ newChipList.push(chipItem);
+ }
+ this.chipList = newChipList;
+}
+
+```
+
+#### Chip Templates
+
+The `IgxChipComponent`'s main structure consists of chip content, `select icon`, `remove button`, `prefix` and `suffix`. All of those elements are templatable.
+
+The content of the chip is taken by the content defined inside the chip template except elements that define the `prefix`or `suffix` of the chip. You can define any type of content you need.
+
+The `prefix` and `suffix` are also elements inside the actual chip area where they can be templated by your preference. The way they can be specified is by using the `IgxPrefix` and `IxgSuffix` directives respectively.
+
+Example of using an icon for a `prefix`, text for content and a custom icon again for a `suffix`:
+
+```html
+
+ drag_indicator
+ {{chip.text}}
+ close
+
+```
+
+#### Keyboard Navigation
+
+The chips can be focused using the `Tab` key or by clicking on them. Chips can be reordered using the keyboard navigation:
+
+- Keyboard controls when the chip is focused:
+
+ - LEFT - Moves the focus to the chip on the left.
+ - RIGHT - Focuses the chip on the right.
+ - SPACE - Toggles chip selection if it is selectable.
+ - DELETE - Triggers the `remove` event for the `igxChip` so the chip deletion can be handled manually
+ - SHIFT + LEFT - Triggers `onReorder` event for the `igxChipArea` when the currently focused chip should move position to the left.
+ - SHIFT + RIGHT - Triggers `onReorder` event for the `igxChipArea` when the currently focused chip should move one position to the right
+
+- Keyboard controls when the remove button is focused:
+
+ - SPACE or ENTER Triggers the `remove` event so the chip deletion can be handled manually
+
+# API
+
+## IgxChipComponent
+
+### Inputs
+| Name | Type | Description |
+|:----------|:-------------:|:------|
+| `id` | `string` | Unique identifier of the component. |
+| `data` | `any` | Stores data related to the chip. |
+| `draggable ` | `boolean` | Defines if the chip can be dragged in order to change its position. |
+| `removable ` | `boolean` | Defines if the chip should render remove button and throw remove events. |
+| `removeIcon ` | `TemplateRef` | Overrides the default remove icon when `removable` is set to `true`. |
+| `selectable ` | `boolen` | Defines if the chip can be selected on click or through navigation. |
+| `selectIcon ` | `TemplateRef` | Overrides the default select icon when `selectable` is set to `true`. |
+| `selected` | `boolen` | Sets if the chip is selected. |
+| `disabled` | `boolean` | Sets if the chip is disabled. |
+| `color` | `string` | Sets the chip background color. |
+| `hideBaseOnDrag` | `boolean` | Sets if the chip base should be hidden when the chip is dragged. |
+
+### Outputs
+| Name | Argument Type | Description |
+|:--:|:---|:---|
+| `moveStart` | `IBaseChipEventArgs` | Fired when the chip moving(dragging) starts. |
+| `moveEnd` | `IBaseChipEventArgs` | Fired when the chip moving(dragging) ends. |
+| `remove ` | `IBaseChipEventArgs` | Fired when the chip remove button is clicked. |
+| `chipClick ` | `IChipClickEventArgs` | Fired when the chip is clicked instead of dragged. |
+| `selectedChanging` | `IChipSelectEventArgs` | Fired when the chip is being selected/deselected. Cancellable |
+| `selectedChange` | |
+| `selectedChanging` | `IChipSelectEventArgs` | Fired when the chip is being selected/deselected. Cancellable |
+| `keyDown ` | `IChipKeyDownEventArgs` | Fired when the chip keyboard navigation is being used. |
+| `dragEnter ` | `IChipEnterDragAreaEventArgs` | Fired when another chip has entered the current chip area. |
+| `dragLeave ` | `IChipEnterDragAreaEventArgs` | Fired when another chip has left the current chip area. |
+| `dragDrop ` | `IChipEnterDragAreaEventArgs` | Fired when another chip has been dropped in the current chip area. |
+| `dragOver ` | `IChipEnterDragAreaEventArgs` | Fired when another chip has moved over the current chip area. |
+
+## IgxChipsAreaComponent
+
+### Inputs
+| Name | Type | Description |
+|:----------|:-------------:|:------|
+| `width` | `number` | Sets the width of the chips area. |
+| `height ` | `number` | Sets the height of the chips area. |
+
+### Outputs
+| Name | Argument Type | Description |
+|:--:|:---|:---|
+| `reorder ` | `IChipsAreaReorderEventArgs` | Fired when the chips order should be changed(from dragging). Requires custom logic for actual reorder. |
+| `selectionChange ` | `IChipsAreaSelectEventArgs` | Fired for all initially selected chips and when chip is being selected/deselected. |
+| `moveStart ` | `IBaseChipsAreaEventArgs` | Fired when any chip moving(dragging) starts. |
+| `moveEnd ` | `IBaseChipsAreaEventArgs` | Fired when any chip moving(dragging) ends. |
+
+### Properties
+| Name | Return Type | Description |
+|:----------:|:------|:------|
+| `chipsList` | `QueryList` | Returns the list of chips inside the chip area. |
diff --git a/projects/igniteui-angular/chips/index.ts b/projects/igniteui-angular/chips/index.ts
new file mode 100644
index 00000000000..decc72d85bc
--- /dev/null
+++ b/projects/igniteui-angular/chips/index.ts
@@ -0,0 +1 @@
+export * from './src/public_api';
diff --git a/projects/igniteui-angular/chips/ng-package.json b/projects/igniteui-angular/chips/ng-package.json
new file mode 100644
index 00000000000..2c63c085104
--- /dev/null
+++ b/projects/igniteui-angular/chips/ng-package.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/projects/igniteui-angular/chips/src/chips/chip.component.html b/projects/igniteui-angular/chips/src/chips/chip.component.html
new file mode 100644
index 00000000000..27958ceca06
--- /dev/null
+++ b/projects/igniteui-angular/chips/src/chips/chip.component.html
@@ -0,0 +1,64 @@
+
+
+
+ @if (selected) {
+
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+ @if (removable) {
+
+
+
+ }
+
+
+
+
+
+
+
+
+
+
diff --git a/projects/igniteui-angular/chips/src/chips/chip.component.ts b/projects/igniteui-angular/chips/src/chips/chip.component.ts
new file mode 100644
index 00000000000..b25a5e646a0
--- /dev/null
+++ b/projects/igniteui-angular/chips/src/chips/chip.component.ts
@@ -0,0 +1,919 @@
+import {
+ Component,
+ ChangeDetectorRef,
+ EventEmitter,
+ ElementRef,
+ HostBinding,
+ HostListener,
+ Input,
+ Output,
+ ViewChild,
+ Renderer2,
+ TemplateRef,
+ OnDestroy,
+ booleanAttribute,
+ OnInit,
+ inject,
+ DOCUMENT
+} from '@angular/core';
+import { IgxDragDirective, IDragBaseEventArgs, IDragStartEventArgs, IDropBaseEventArgs, IDropDroppedEventArgs, IgxDropDirective } from 'igniteui-angular/directives';
+import { IBaseEventArgs, ɵSize } from 'igniteui-angular/core';
+import { ChipResourceStringsEN, IChipResourceStrings } from 'igniteui-angular/core';
+import { Subject } from 'rxjs';
+import { IgxIconComponent } from 'igniteui-angular/icon';
+import { NgClass, NgTemplateOutlet } from '@angular/common';
+import { getCurrentResourceStrings } from 'igniteui-angular/core';
+
+export const IgxChipTypeVariant = {
+ PRIMARY: 'primary',
+ INFO: 'info',
+ SUCCESS: 'success',
+ WARNING: 'warning',
+ DANGER: 'danger'
+} as const;
+export type IgxChipTypeVariant = (typeof IgxChipTypeVariant)[keyof typeof IgxChipTypeVariant];
+
+export interface IBaseChipEventArgs extends IBaseEventArgs {
+ originalEvent: IDragBaseEventArgs | IDropBaseEventArgs | KeyboardEvent | MouseEvent | TouchEvent;
+ owner: IgxChipComponent;
+}
+
+export interface IChipClickEventArgs extends IBaseChipEventArgs {
+ cancel: boolean;
+}
+
+export interface IChipKeyDownEventArgs extends IBaseChipEventArgs {
+ originalEvent: KeyboardEvent;
+ cancel: boolean;
+}
+
+export interface IChipEnterDragAreaEventArgs extends IBaseChipEventArgs {
+ dragChip: IgxChipComponent;
+}
+
+export interface IChipSelectEventArgs extends IBaseChipEventArgs {
+ cancel: boolean;
+ selected: boolean;
+}
+
+let CHIP_ID = 0;
+
+/**
+ * Chip is compact visual component that displays information in an obround.
+ *
+ * @igxModule IgxChipsModule
+ *
+ * @igxTheme igx-chip-theme
+ *
+ * @igxKeywords chip
+ *
+ * @igxGroup display
+ *
+ * @remarks
+ * The Ignite UI Chip can be templated, deleted, and selected.
+ * Multiple chips can be reordered and visually connected to each other.
+ * Chips reside in a container called chips area which is responsible for managing the interactions between the chips.
+ *
+ * @example
+ * ```html
+ *
+ *
+ *
+ * ```
+ */
+@Component({
+ selector: 'igx-chip',
+ templateUrl: 'chip.component.html',
+ imports: [IgxDropDirective, IgxDragDirective, NgClass, NgTemplateOutlet, IgxIconComponent]
+})
+export class IgxChipComponent implements OnInit, OnDestroy {
+ public cdr = inject(ChangeDetectorRef);
+ private ref = inject>(ElementRef);
+ private renderer = inject(Renderer2);
+ public document = inject(DOCUMENT);
+
+
+ /**
+ * Sets/gets the variant of the chip.
+ *
+ * @remarks
+ * Allowed values are `primary`, `info`, `success`, `warning`, `danger`.
+ * Providing no/nullish value leaves the chip in its default state.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @Input()
+ public variant?: IgxChipTypeVariant | null;
+ /**
+ * Sets the value of `id` attribute. If not provided it will be automatically generated.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @HostBinding('attr.id')
+ @Input()
+ public id = `igx-chip-${CHIP_ID++}`;
+
+ /**
+ * Returns the `role` attribute of the chip.
+ *
+ * @example
+ * ```typescript
+ * let chipRole = this.chip.role;
+ * ```
+ */
+ @HostBinding('attr.role')
+ public role = 'option';
+
+ /**
+ * Sets the value of `tabindex` attribute. If not provided it will use the element's tabindex if set.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @HostBinding('attr.tabIndex')
+ @Input()
+ public set tabIndex(value: number) {
+ this._tabIndex = value;
+ }
+
+ public get tabIndex() {
+ if (this._tabIndex !== null) {
+ return this._tabIndex;
+ }
+ return !this.disabled ? 0 : null;
+ }
+
+ /**
+ * Stores data related to the chip.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @Input()
+ public data: any;
+
+ /**
+ * Defines if the `IgxChipComponent` can be dragged in order to change it's position.
+ * By default it is set to false.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @Input({ transform: booleanAttribute })
+ public draggable = false;
+
+ /**
+ * Enables/disables the draggable element animation when the element is released.
+ * By default it's set to true.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @Input({ transform: booleanAttribute })
+ public animateOnRelease = true;
+
+ /**
+ * Enables/disables the hiding of the base element that has been dragged.
+ * By default it's set to true.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @Input({ transform: booleanAttribute })
+ public hideBaseOnDrag = true;
+
+ /**
+ * Defines if the `IgxChipComponent` should render remove button and throw remove events.
+ * By default it is set to false.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @Input({ transform: booleanAttribute })
+ public removable = false;
+
+ /**
+ * Overrides the default icon that the chip applies to the remove button.
+ *
+ * @example
+ * ```html
+ *
+ * delete
+ * ```
+ */
+ @Input()
+ public removeIcon: TemplateRef;
+
+ /**
+ * Defines if the `IgxChipComponent` can be selected on click or through navigation,
+ * By default it is set to false.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @Input({ transform: booleanAttribute })
+ public selectable = false;
+
+ /**
+ * Overrides the default icon that the chip applies when it is selected.
+ *
+ * @example
+ * ```html
+ *
+ * done_outline
+ * ```
+ */
+ @Input()
+ public selectIcon: TemplateRef;
+
+ /**
+ * @hidden
+ * @internal
+ */
+ @Input()
+ public class = '';
+
+ /**
+ * Disables the `IgxChipComponent`. When disabled it restricts user interactions
+ * like focusing on click or tab, selection on click or Space, dragging.
+ * By default it is set to false.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @HostBinding('class.igx-chip--disabled')
+ @Input({ transform: booleanAttribute })
+ public disabled = false;
+
+ /**
+ * Sets the `IgxChipComponent` selected state.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ *
+ * Two-way data binding:
+ * ```html
+ *
+ * ```
+ */
+ @HostBinding('attr.aria-selected')
+ @Input({ transform: booleanAttribute })
+ public set selected(newValue: boolean) {
+ this.changeSelection(newValue);
+ }
+
+ /**
+ * Returns if the `IgxChipComponent` is selected.
+ *
+ * @example
+ * ```typescript
+ * @ViewChild('myChip')
+ * public chip: IgxChipComponent;
+ * selectedChip(){
+ * let selectedChip = this.chip.selected;
+ * }
+ * ```
+ */
+ public get selected() {
+ return this._selected;
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ @Output()
+ public selectedChange = new EventEmitter();
+
+ /**
+ * Sets the `IgxChipComponent` background color.
+ * The `color` property supports string, rgb, hex.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @Input()
+ public set color(newColor) {
+ this.chipArea.nativeElement.style.backgroundColor = newColor;
+ }
+
+ /**
+ * Returns the background color of the `IgxChipComponent`.
+ *
+ * @example
+ * ```typescript
+ * @ViewChild('myChip')
+ * public chip: IgxChipComponent;
+ * ngAfterViewInit(){
+ * let chipColor = this.chip.color;
+ * }
+ * ```
+ */
+ public get color() {
+ return this.chipArea.nativeElement.style.backgroundColor;
+ }
+
+ /**
+ * An accessor that sets the resource strings.
+ * By default it uses EN resources.
+ */
+ @Input()
+ public set resourceStrings(value: IChipResourceStrings) {
+ this._resourceStrings = Object.assign({}, this._resourceStrings, value);
+ }
+
+ /**
+ * An accessor that returns the resource strings.
+ */
+ public get resourceStrings(): IChipResourceStrings {
+ return this._resourceStrings;
+ }
+
+ /**
+ * Emits an event when the `IgxChipComponent` moving starts.
+ * Returns the moving `IgxChipComponent`.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @Output()
+ public moveStart = new EventEmitter();
+
+ /**
+ * Emits an event when the `IgxChipComponent` moving ends.
+ * Returns the moved `IgxChipComponent`.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @Output()
+ public moveEnd = new EventEmitter();
+
+ /**
+ * Emits an event when the `IgxChipComponent` is removed.
+ * Returns the removed `IgxChipComponent`.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @Output()
+ public remove = new EventEmitter();
+
+ /**
+ * Emits an event when the `IgxChipComponent` is clicked.
+ * Returns the clicked `IgxChipComponent`, whether the event should be canceled.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @Output()
+ public chipClick = new EventEmitter();
+
+ /**
+ * Emits event when the `IgxChipComponent` is selected/deselected.
+ * Returns the selected chip reference, whether the event should be canceled, what is the next selection state and
+ * when the event is triggered by interaction `originalEvent` is provided, otherwise `originalEvent` is `null`.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @Output()
+ public selectedChanging = new EventEmitter();
+
+ /**
+ * Emits event when the `IgxChipComponent` is selected/deselected and any related animations and transitions also end.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @Output()
+ public selectedChanged = new EventEmitter();
+
+ /**
+ * Emits an event when the `IgxChipComponent` keyboard navigation is being used.
+ * Returns the focused/selected `IgxChipComponent`, whether the event should be canceled,
+ * if the `alt`, `shift` or `control` key is pressed and the pressed key name.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @Output()
+ public keyDown = new EventEmitter();
+
+ /**
+ * Emits an event when the `IgxChipComponent` has entered the `IgxChipsAreaComponent`.
+ * Returns the target `IgxChipComponent`, the drag `IgxChipComponent`, as well as
+ * the original drop event arguments.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @Output()
+ public dragEnter = new EventEmitter();
+
+ /**
+ * Emits an event when the `IgxChipComponent` has left the `IgxChipsAreaComponent`.
+ * Returns the target `IgxChipComponent`, the drag `IgxChipComponent`, as well as
+ * the original drop event arguments.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @Output()
+ public dragLeave = new EventEmitter();
+
+ /**
+ * Emits an event when the `IgxChipComponent` is over the `IgxChipsAreaComponent`.
+ * Returns the target `IgxChipComponent`, the drag `IgxChipComponent`, as well as
+ * the original drop event arguments.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @Output()
+ public dragOver = new EventEmitter();
+
+ /**
+ * Emits an event when the `IgxChipComponent` has been dropped in the `IgxChipsAreaComponent`.
+ * Returns the target `IgxChipComponent`, the drag `IgxChipComponent`, as well as
+ * the original drop event arguments.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @Output()
+ public dragDrop = new EventEmitter();
+
+ @HostBinding('class.igx-chip')
+ protected defaultClass = 'igx-chip';
+
+ @HostBinding('class.igx-chip--primary')
+ protected get isPrimary() {
+ return this.variant === IgxChipTypeVariant.PRIMARY;
+ }
+
+ @HostBinding('class.igx-chip--info')
+ protected get isInfo() {
+ return this.variant === IgxChipTypeVariant.INFO;
+ }
+
+ @HostBinding('class.igx-chip--success')
+ protected get isSuccess() {
+ return this.variant === IgxChipTypeVariant.SUCCESS;
+ }
+
+ @HostBinding('class.igx-chip--warning')
+ protected get isWarning() {
+ return this.variant === IgxChipTypeVariant.WARNING;
+ }
+
+ @HostBinding('class.igx-chip--danger')
+ protected get isDanger() {
+ return this.variant === IgxChipTypeVariant.DANGER;
+ }
+
+ /**
+ * Property that contains a reference to the `IgxDragDirective` the `IgxChipComponent` uses for dragging behavior.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ * ```typescript
+ * onMoveStart(event: IBaseChipEventArgs){
+ * let dragDirective = event.owner.dragDirective;
+ * }
+ * ```
+ */
+ @ViewChild('chipArea', { read: IgxDragDirective, static: true })
+ public dragDirective: IgxDragDirective;
+
+ /**
+ * @hidden
+ * @internal
+ */
+ @ViewChild('chipArea', { read: ElementRef, static: true })
+ public chipArea: ElementRef;
+
+ /**
+ * @hidden
+ * @internal
+ */
+ @ViewChild('defaultRemoveIcon', { read: TemplateRef, static: true })
+ public defaultRemoveIcon: TemplateRef;
+
+ /**
+ * @hidden
+ * @internal
+ */
+ @ViewChild('defaultSelectIcon', { read: TemplateRef, static: true })
+ public defaultSelectIcon: TemplateRef;
+
+ /**
+ * @hidden
+ * @internal
+ */
+ public get removeButtonTemplate() {
+ if (!this.disabled) {
+ return this.removeIcon || this.defaultRemoveIcon;
+ }
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ public get selectIconTemplate() {
+ return this.selectIcon || this.defaultSelectIcon;
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ public get ghostStyles() {
+ return { '--ig-size': `${this.chipSize}` };
+ }
+
+ /** @hidden @internal */
+ public get nativeElement() {
+ return this.ref.nativeElement;
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ public hideBaseElement = false;
+
+ /**
+ * @hidden
+ * @internal
+ */
+ public destroy$ = new Subject();
+
+ protected get chipSize(): ɵSize {
+ return this.computedStyles?.getPropertyValue('--ig-size') || ɵSize.Medium;
+ }
+ protected _tabIndex = null;
+ protected _selected = false;
+ protected _selectedItemClass = 'igx-chip__item--selected';
+ protected _movedWhileRemoving = false;
+ protected computedStyles;
+ private _resourceStrings = getCurrentResourceStrings(ChipResourceStringsEN);
+
+ /**
+ * @hidden
+ * @internal
+ */
+ @HostListener('keydown', ['$event'])
+ public keyEvent(event: KeyboardEvent) {
+ this.onChipKeyDown(event);
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ public selectClass(condition: boolean): any {
+ const SELECT_CLASS = 'igx-chip__select';
+
+ return {
+ [SELECT_CLASS]: condition,
+ [`${SELECT_CLASS}--hidden`]: !condition
+ };
+ }
+
+ public onSelectTransitionDone(event) {
+ if (event.target.tagName) {
+ // Trigger onSelectionDone on when `width` property is changed and the target is valid element(not comment).
+ this.selectedChanged.emit({
+ owner: this,
+ originalEvent: event
+ });
+ }
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ public onChipKeyDown(event: KeyboardEvent) {
+ const keyDownArgs: IChipKeyDownEventArgs = {
+ originalEvent: event,
+ owner: this,
+ cancel: false
+ };
+
+ this.keyDown.emit(keyDownArgs);
+ if (keyDownArgs.cancel) {
+ return;
+ }
+
+ if ((event.key === 'Delete' || event.key === 'Del') && this.removable) {
+ this.remove.emit({
+ originalEvent: event,
+ owner: this
+ });
+ }
+
+ if ((event.key === ' ' || event.key === 'Spacebar') && this.selectable && !this.disabled) {
+ this.changeSelection(!this.selected, event);
+ }
+
+ if (event.key !== 'Tab') {
+ event.preventDefault();
+ }
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ public onRemoveBtnKeyDown(event: KeyboardEvent) {
+ if (event.key === ' ' || event.key === 'Spacebar' || event.key === 'Enter') {
+ this.remove.emit({
+ originalEvent: event,
+ owner: this
+ });
+
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ }
+
+ public onRemoveMouseDown(event: PointerEvent | MouseEvent) {
+ event.stopPropagation();
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ public onRemoveClick(event: MouseEvent | TouchEvent) {
+ this.remove.emit({
+ originalEvent: event,
+ owner: this
+ });
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ public onRemoveTouchMove() {
+ // We don't remove chip if user starting touch interacting on the remove button moves the chip
+ this._movedWhileRemoving = true;
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ public onRemoveTouchEnd(event: TouchEvent) {
+ if (!this._movedWhileRemoving) {
+ this.onRemoveClick(event);
+ }
+ this._movedWhileRemoving = false;
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ // -----------------------------
+ // Start chip igxDrag behavior
+ public onChipDragStart(event: IDragStartEventArgs) {
+ this.moveStart.emit({
+ originalEvent: event,
+ owner: this
+ });
+ event.cancel = !this.draggable || this.disabled;
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ public onChipDragEnd() {
+ if (this.animateOnRelease) {
+ this.dragDirective.transitionToOrigin();
+ }
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ public onChipMoveEnd(event: IDragBaseEventArgs) {
+ // moveEnd is triggered after return animation has finished. This happen when we drag and release the chip.
+ this.moveEnd.emit({
+ originalEvent: event,
+ owner: this
+ });
+
+ if (this.selected) {
+ this.chipArea.nativeElement.focus();
+ }
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ public onChipGhostCreate() {
+ this.hideBaseElement = this.hideBaseOnDrag;
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ public onChipGhostDestroy() {
+ this.hideBaseElement = false;
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ public onChipDragClicked(event: IDragBaseEventArgs) {
+ const clickEventArgs: IChipClickEventArgs = {
+ originalEvent: event,
+ owner: this,
+ cancel: false
+ };
+ this.chipClick.emit(clickEventArgs);
+
+ if (!clickEventArgs.cancel && this.selectable && !this.disabled) {
+ this.changeSelection(!this.selected, event);
+ }
+ }
+ // End chip igxDrag behavior
+
+ /**
+ * @hidden
+ * @internal
+ */
+ // -----------------------------
+ // Start chip igxDrop behavior
+ public onChipDragEnterHandler(event: IDropBaseEventArgs) {
+ if (this.dragDirective === event.drag) {
+ return;
+ }
+
+ const eventArgs: IChipEnterDragAreaEventArgs = {
+ owner: this,
+ dragChip: event.drag.data?.chip,
+ originalEvent: event
+ };
+ this.dragEnter.emit(eventArgs);
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ public onChipDragLeaveHandler(event: IDropBaseEventArgs) {
+ if (this.dragDirective === event.drag) {
+ return;
+ }
+
+ const eventArgs: IChipEnterDragAreaEventArgs = {
+ owner: this,
+ dragChip: event.drag.data?.chip,
+ originalEvent: event
+ };
+ this.dragLeave.emit(eventArgs);
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ public onChipDrop(event: IDropDroppedEventArgs) {
+ // Cancel the default drop logic
+ event.cancel = true;
+ if (this.dragDirective === event.drag) {
+ return;
+ }
+
+ const eventArgs: IChipEnterDragAreaEventArgs = {
+ owner: this,
+ dragChip: event.drag.data?.chip,
+ originalEvent: event
+ };
+ this.dragDrop.emit(eventArgs);
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ public onChipOverHandler(event: IDropBaseEventArgs) {
+ if (this.dragDirective === event.drag) {
+ return;
+ }
+
+ const eventArgs: IChipEnterDragAreaEventArgs = {
+ owner: this,
+ dragChip: event.drag.data?.chip,
+ originalEvent: event
+ };
+ this.dragOver.emit(eventArgs);
+ }
+ // End chip igxDrop behavior
+
+ protected changeSelection(newValue: boolean, srcEvent = null) {
+ const onSelectArgs: IChipSelectEventArgs = {
+ originalEvent: srcEvent,
+ owner: this,
+ selected: false,
+ cancel: false
+ };
+
+ if (newValue && !this._selected) {
+ onSelectArgs.selected = true;
+ this.selectedChanging.emit(onSelectArgs);
+
+ if (!onSelectArgs.cancel) {
+ this.renderer.addClass(this.chipArea.nativeElement, this._selectedItemClass);
+ this._selected = newValue;
+ this.selectedChange.emit(this._selected);
+ this.selectedChanged.emit({
+ owner: this,
+ originalEvent: srcEvent
+ });
+ }
+ } else if (!newValue && this._selected) {
+ this.selectedChanging.emit(onSelectArgs);
+
+ if (!onSelectArgs.cancel) {
+ this.renderer.removeClass(this.chipArea.nativeElement, this._selectedItemClass);
+ this._selected = newValue;
+ this.selectedChange.emit(this._selected);
+ this.selectedChanged.emit({
+ owner: this,
+ originalEvent: srcEvent
+ });
+ }
+ }
+ }
+
+ public ngOnInit(): void {
+ this.computedStyles = this.document.defaultView.getComputedStyle(this.nativeElement);
+ }
+
+ public ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+}
diff --git a/projects/igniteui-angular/chips/src/chips/chip.spec.ts b/projects/igniteui-angular/chips/src/chips/chip.spec.ts
new file mode 100644
index 00000000000..b7c54bf7951
--- /dev/null
+++ b/projects/igniteui-angular/chips/src/chips/chip.spec.ts
@@ -0,0 +1,407 @@
+import { Component, ViewChild, ViewChildren, QueryList, ChangeDetectorRef, inject } from '@angular/core';
+import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { IgxChipComponent } from './chip.component';
+import { IgxChipsAreaComponent } from './chips-area.component';
+import { IgxPrefixDirective } from '../../../input-group/src/public_api';
+import { IgxLabelDirective } from '../../../input-group/src/public_api';
+import { IgxSuffixDirective } from '../../../input-group/src/public_api';
+import { IgxIconComponent } from 'igniteui-angular/icon';
+import { getComponentSize } from 'igniteui-angular/core';
+import { ControlsFunction } from 'igniteui-angular/test-utils/controls-functions.spec';
+import { UIInteractions, wait } from 'igniteui-angular/test-utils/ui-interactions.spec';
+
+@Component({
+ template: `
+
+ @for (chip of chipList; track chip.id) {
+
+ {{chip.text}}
+ drag_indicator
+
+ }
+
+ Tab Chip
+
+
+ Tab Chip
+
+
+ Tab Chip
+
+
+ Tab Chip
+
+
+ Tab Chip
+
+
+ `,
+ imports: [IgxChipComponent, IgxChipsAreaComponent, IgxIconComponent, IgxPrefixDirective]
+})
+class TestChipComponent {
+ public cdr = inject(ChangeDetectorRef);
+
+
+ @ViewChild('chipsArea', { read: IgxChipsAreaComponent, static: true })
+ public chipsArea: IgxChipsAreaComponent;
+
+ @ViewChildren('chipElem', { read: IgxChipComponent })
+ public chips: QueryList;
+
+ public chipList = [
+ { id: 'Country', text: 'Country', removable: false, selectable: false, draggable: true },
+ { id: 'City', text: 'City', removable: true, selectable: true, draggable: true, chipSize: '--ig-size-large' },
+ { id: 'Town', text: 'Town', removable: true, selectable: true, draggable: true, chipSize: '--ig-size-small' },
+ { id: 'FirstName', text: 'First Name', removable: true, selectable: true, draggable: true, chipSize: '--ig-size-medium' }
+ ];
+
+ public chipRemoved(event) {
+ this.chipList = this.chipList.filter((item) => item.id !== event.owner.id);
+ this.cdr.detectChanges();
+ }
+}
+
+@Component({
+ template: `
+
+ @for (chip of chipList; track chip.id) {
+
+ label
+ suf
+
+ }
+
+ `,
+ imports: [IgxChipsAreaComponent, IgxChipComponent, IgxLabelDirective, IgxSuffixDirective]
+})
+class TestChipsLabelAndSuffixComponent {
+
+ @ViewChild('chipsArea', { read: IgxChipsAreaComponent, static: true })
+ public chipsArea: IgxChipsAreaComponent;
+
+ @ViewChildren('chipElem', { read: IgxChipComponent })
+ public chips: QueryList;
+
+ public chipList = [
+ { id: 'Country', text: 'Country', removable: false, selectable: false, draggable: true },
+ { id: 'City', text: 'City', removable: true, selectable: true, draggable: true },
+ { id: 'Town', text: 'Town', removable: true, selectable: true, draggable: true },
+ { id: 'FirstName', text: 'First Name', removable: true, selectable: true, draggable: true },
+ ];
+}
+
+
+describe('IgxChip', () => {
+ const CHIP_TEXT_CLASS = 'igx-chip__text';
+ const CHIP_ITEM_CLASS = 'igx-chip__item';
+
+ let fix: ComponentFixture;
+ let chipArea;
+
+ beforeEach(waitForAsync(() => {
+ TestBed.configureTestingModule({
+ imports: [TestChipComponent, TestChipsLabelAndSuffixComponent]
+ }).compileComponents();
+ }));
+
+ describe('Rendering Tests: ', () => {
+ beforeEach(() => {
+ fix = TestBed.createComponent(TestChipComponent);
+ fix.detectChanges();
+ chipArea = fix.debugElement.queryAll(By.directive(IgxChipsAreaComponent));
+ });
+
+ it('should render chip area and chips inside it', () => {
+ expect(chipArea.length).toEqual(1);
+ expect(chipArea[0].nativeElement.children.length).toEqual(9);
+ expect(chipArea[0].nativeElement.children[0].tagName).toEqual('IGX-CHIP');
+ });
+
+ it('should render prefix element inside the chip before the content', () => {
+ const igxChip = fix.debugElement.queryAll(By.directive(IgxChipComponent));
+ const igxChipItem = igxChip[1].nativeElement;
+
+ expect(igxChipItem.children[0].children[0].children[0].hasAttribute('igxprefix')).toEqual(true);
+ });
+
+ it('should render remove button when enabled after the content inside the chip', () => {
+ const igxChip = fix.debugElement.queryAll(By.directive(IgxChipComponent));
+ const igxChipItem = igxChip[1].nativeElement;
+ const chipRemoveButton = ControlsFunction.getChipRemoveButton(igxChipItem);
+
+ expect(igxChipItem.children[0].children[2].children[0]).toHaveClass('igx-chip__remove');
+ expect(chipRemoveButton).toBeTruthy();
+ });
+
+ it('should change chip variant', () => {
+ const fixture = TestBed.createComponent(IgxChipComponent);
+ const igxChip = fixture.componentInstance;
+ // For test fixture destroy
+ igxChip.id = "root1";
+
+ igxChip.variant = 'danger';
+
+ fixture.detectChanges();
+
+ expect(igxChip.variant).toMatch('danger');
+ expect(igxChip.nativeElement).toHaveClass('igx-chip--danger');
+ });
+
+ it('should set text in chips correctly', () => {
+ const chipElements = chipArea[0].queryAll(By.directive(IgxChipComponent));
+ const firstChipTextElement = chipElements[0].queryAllNodes(By.css(`.${CHIP_TEXT_CLASS}`));
+ const firstChipText = firstChipTextElement[0].nativeNode.innerHTML;
+
+ expect(firstChipText).toContain('Country');
+
+ const secondChipTextElement = chipElements[1].queryAllNodes(By.css(`.${CHIP_TEXT_CLASS}`));
+ const secondChipText = secondChipTextElement[0].nativeNode.innerHTML;
+
+ expect(secondChipText).toContain('City');
+ });
+
+ it('should set chips prefix correctly', () => {
+ const chipElements = chipArea[0].queryAll(By.directive(IgxChipComponent));
+ const firstChipPrefix = chipElements[0].queryAll(By.directive(IgxPrefixDirective));
+ const firstChipIconName = firstChipPrefix[0].nativeElement.textContent;
+
+ expect(firstChipIconName).toContain('drag_indicator');
+ });
+
+ it('should set correctly color of chip when color is set through code', () => {
+ const chipColor = 'rgb(255, 0, 0)';
+
+ const components = fix.debugElement.queryAll(By.directive(IgxChipComponent));
+ const firstComponent = components[0];
+ const chipAreaElem = firstComponent.queryAll(By.css(`.${CHIP_ITEM_CLASS}`))[0];
+
+ firstComponent.componentInstance.color = chipColor;
+
+ expect(chipAreaElem.nativeElement.style.backgroundColor).toEqual(chipColor);
+ expect(firstComponent.componentInstance.color).toEqual(chipColor);
+ });
+
+ it('should apply correct tabIndex to the chip area only when tabIndex is set as property of the chip and chip is disabled', () => {
+ const firstTabChip = fix.debugElement.queryAll(By.directive(IgxChipComponent))[4];
+ expect(firstTabChip.nativeElement.getAttribute('tabindex')).toEqual('1');
+
+ // Chip is disabled, but attribute tabindex has bigger priority.
+ const secondTabChip = fix.debugElement.queryAll(By.directive(IgxChipComponent))[5];
+ expect(secondTabChip.nativeElement.getAttribute('tabindex')).toEqual('2');
+ });
+
+ it('should apply correct tab indexes when tabIndex and removeTabIndex are set as inputs', () => {
+ const thirdTabChip = fix.debugElement.queryAll(By.directive(IgxChipComponent))[6];
+ const deleteBtn = ControlsFunction.getChipRemoveButton(thirdTabChip.componentInstance.chipArea.nativeElement);
+ expect(thirdTabChip.nativeElement.getAttribute('tabindex')).toEqual('3');
+ expect(deleteBtn.getAttribute('tabindex')).toEqual('3');
+
+ // tabIndex attribute has higher priority than tabIndex.
+ const fourthTabChip = fix.debugElement.queryAll(By.directive(IgxChipComponent))[7];
+ expect(fourthTabChip.nativeElement.getAttribute('tabindex')).toEqual('1');
+
+ // tabIndex attribute has higher priority than tabIndex input and chip being disabled.
+ const fifthTabChip = fix.debugElement.queryAll(By.directive(IgxChipComponent))[8];
+ expect(fifthTabChip.nativeElement.getAttribute('tabindex')).toEqual('1');
+ });
+ });
+
+ describe('Interactions Tests: ', () => {
+ beforeEach(() => {
+ fix = TestBed.createComponent(TestChipComponent);
+ fix.detectChanges();
+ });
+
+ it('should not trigger remove event when delete button is pressed when not removable', () => {
+ const firstChipComp = fix.componentInstance.chips.toArray()[0];
+
+ spyOn(firstChipComp.remove, 'emit');
+ UIInteractions.triggerKeyDownEvtUponElem('Delete', firstChipComp.chipArea.nativeElement, true);
+ fix.detectChanges();
+
+ expect(firstChipComp.remove.emit).not.toHaveBeenCalled();
+ });
+
+ it('should trigger remove event when delete button is pressed when removable', () => {
+ const secondChipComp = fix.componentInstance.chips.toArray()[1];
+
+ spyOn(secondChipComp.remove, 'emit');
+ UIInteractions.triggerKeyDownEvtUponElem('Delete', secondChipComp.chipArea.nativeElement, true);
+ fix.detectChanges();
+
+ expect(secondChipComp.remove.emit).toHaveBeenCalled();
+ });
+
+ it('should delete chip when space button is pressed on delete button', () => {
+ HelperTestFunctions.verifyChipsCount(fix, 9);
+ const chipElems = fix.debugElement.queryAll(By.directive(IgxChipComponent));
+ const deleteButtonElement = ControlsFunction.getChipRemoveButton(chipElems[1].nativeElement);
+ // Removes chip with id City, because country chip is unremovable
+ UIInteractions.triggerKeyDownEvtUponElem(' ', deleteButtonElement, true);
+ fix.detectChanges();
+
+ HelperTestFunctions.verifyChipsCount(fix, 8);
+
+ const chipComponentsIds = fix.componentInstance.chipList.map(c => c.id);
+ expect(chipComponentsIds.length).toEqual(3);
+ expect(chipComponentsIds).not.toContain('City');
+ });
+
+ it('should delete chip when enter button is pressed on delete button', () => {
+ HelperTestFunctions.verifyChipsCount(fix, 9);
+
+ const chipElems = fix.debugElement.queryAll(By.directive(IgxChipComponent));
+ const deleteButtonElement = ControlsFunction.getChipRemoveButton(chipElems[1].nativeElement);
+ // Removes chip with id City, because country chip is unremovable
+ UIInteractions.triggerKeyDownEvtUponElem('Enter', deleteButtonElement, true);
+ fix.detectChanges();
+
+ HelperTestFunctions.verifyChipsCount(fix, 8);
+
+ const chipComponentsIds = fix.componentInstance.chipList.map(c => c.id);
+ expect(chipComponentsIds.length).toEqual(3);
+ expect(chipComponentsIds).not.toContain('City');
+ });
+
+ it('should affect the ghostElement size when chip has it set to compact', () => {
+ const thirdChip = fix.componentInstance.chips.toArray()[2];
+ const thirdChipElem = thirdChip.chipArea.nativeElement;
+
+ const startingTop = thirdChipElem.getBoundingClientRect().top;
+ const startingLeft = thirdChipElem.getBoundingClientRect().left;
+ const startingBottom = thirdChipElem.getBoundingClientRect().bottom;
+ const startingRight = thirdChipElem.getBoundingClientRect().right;
+
+ const startingX = (startingLeft + startingRight) / 2;
+ const startingY = (startingTop + startingBottom) / 2;
+
+ UIInteractions.simulatePointerEvent('pointerdown', thirdChipElem, startingX, startingY);
+ fix.detectChanges();
+
+ UIInteractions.simulatePointerEvent('pointermove', thirdChipElem, startingX + 10, startingY + 10);
+ fix.detectChanges();
+
+ expect(getComponentSize(thirdChip.dragDirective.ghostElement)).toEqual('1');
+ });
+
+ it('should fire selectedChanging event when selectable is true', () => {
+ const secondChipComp = fix.componentInstance.chips.toArray()[1];
+ spyOn(secondChipComp.selectedChanging, 'emit');
+ spyOn(secondChipComp.selectedChanged, 'emit');
+
+ UIInteractions.triggerKeyDownEvtUponElem(' ', secondChipComp.chipArea.nativeElement, true);
+ fix.detectChanges();
+ expect(secondChipComp.selectedChanging.emit).toHaveBeenCalled();
+ expect(secondChipComp.selectedChanged.emit).toHaveBeenCalled();
+ expect(secondChipComp.selectedChanging.emit).not.toHaveBeenCalledWith({
+ originalEvent: null,
+ owner: secondChipComp,
+ cancel: false,
+ selected: true
+ });
+
+ expect(secondChipComp.selectedChanging.emit).toHaveBeenCalledWith({
+ originalEvent: jasmine.anything(),
+ owner: secondChipComp,
+ cancel: false,
+ selected: true
+ });
+ });
+
+ it('should fire selectedChanged event when selectable is true', (async () => {
+ pending('This should be tested in the e2e test');
+ const secondChipComp: IgxChipComponent = fix.componentInstance.chips.toArray()[1];
+
+ spyOn(secondChipComp.selectedChanging, 'emit');
+ spyOn(secondChipComp.selectedChanged, 'emit');
+ secondChipComp.chipArea.nativeElement.focus();
+
+ UIInteractions.triggerKeyDownEvtUponElem(' ', secondChipComp.chipArea.nativeElement, true);
+ fix.detectChanges();
+ expect(secondChipComp.selectedChanging.emit).toHaveBeenCalled();
+ expect(secondChipComp.selectedChanged.emit).not.toHaveBeenCalled();
+ expect(secondChipComp.selectedChanging.emit).not.toHaveBeenCalledWith({
+ originalEvent: null,
+ owner: secondChipComp,
+ cancel: false,
+ selected: true
+ });
+
+ await wait(400);
+ expect(secondChipComp.selectedChanged.emit).toHaveBeenCalledTimes(1);
+ expect(secondChipComp.selectedChanged.emit).not.toHaveBeenCalledWith({
+ originalEvent: null,
+ owner: secondChipComp
+ });
+ }));
+
+ it('should not fire selectedChanging event when selectable is false', () => {
+ const firstChipComp: IgxChipComponent = fix.componentInstance.chips.toArray()[0];
+
+ spyOn(firstChipComp.selectedChanging, 'emit');
+ spyOn(firstChipComp.selectedChanged, 'emit');
+ firstChipComp.nativeElement.focus();
+
+ UIInteractions.triggerKeyDownEvtUponElem(' ', firstChipComp.chipArea.nativeElement, true);
+ fix.detectChanges();
+ expect(firstChipComp.selectedChanging.emit).toHaveBeenCalledTimes(0);
+ expect(firstChipComp.selectedChanged.emit).toHaveBeenCalledTimes(0);
+ });
+
+ it('should not fire selectedChanging event when the remove button is clicked', () => {
+ const secondChipComp: IgxChipComponent = fix.componentInstance.chips.toArray()[1];
+
+ spyOn(secondChipComp.selectedChanging, 'emit');
+ spyOn(secondChipComp.selectedChanged, 'emit');
+
+ const chipRemoveButton = ControlsFunction.getChipRemoveButton(secondChipComp.chipArea.nativeElement);
+
+ const removeBtnTop = chipRemoveButton.getBoundingClientRect().top;
+ const removeBtnLeft = chipRemoveButton.getBoundingClientRect().left;
+
+ UIInteractions.simulatePointerEvent('pointerdown', chipRemoveButton, removeBtnLeft, removeBtnTop);
+ fix.detectChanges();
+ UIInteractions.simulatePointerEvent('pointerup', chipRemoveButton, removeBtnLeft, removeBtnTop);
+ fix.detectChanges();
+
+ expect(secondChipComp.selectedChanging.emit).not.toHaveBeenCalled();
+ expect(secondChipComp.selectedChanged.emit).not.toHaveBeenCalled();
+ // console.log('id', secondChipComp.id);
+ });
+ });
+
+ describe('Chips Label Tests: ', () => {
+ beforeEach(() => {
+ fix = TestBed.createComponent(TestChipsLabelAndSuffixComponent);
+ fix.detectChanges();
+ chipArea = fix.debugElement.queryAll(By.directive(IgxChipsAreaComponent));
+ });
+
+ it('should set chips label correctly', () => {
+ const chipElements = chipArea[0].queryAll(By.directive(IgxChipComponent));
+ const firstChipLabel = chipElements[0].queryAll(By.directive(IgxLabelDirective));
+ const firstChipLabelText = firstChipLabel[0].nativeElement.innerHTML;
+
+ expect(firstChipLabelText).toEqual('label');
+ });
+
+ it('should set chips suffix correctly', () => {
+ const chipElements = chipArea[0].queryAll(By.directive(IgxChipComponent));
+ const firstChipSuffix = chipElements[0].queryAll(By.directive(IgxSuffixDirective));
+ const firstChipSuffixText = firstChipSuffix[0].nativeElement.innerHTML;
+
+ expect(firstChipSuffixText).toEqual('suf');
+ });
+ });
+});
+
+class HelperTestFunctions {
+ public static verifyChipsCount(fix, count) {
+ const chipComponents = fix.debugElement.queryAll(By.directive(IgxChipComponent));
+ expect(chipComponents.length).toEqual(count);
+ }
+}
diff --git a/projects/igniteui-angular/chips/src/chips/chips-area.component.html b/projects/igniteui-angular/chips/src/chips/chips-area.component.html
new file mode 100644
index 00000000000..6dbc7430638
--- /dev/null
+++ b/projects/igniteui-angular/chips/src/chips/chips-area.component.html
@@ -0,0 +1 @@
+
diff --git a/projects/igniteui-angular/chips/src/chips/chips-area.component.ts b/projects/igniteui-angular/chips/src/chips/chips-area.component.ts
new file mode 100644
index 00000000000..1146c073d6c
--- /dev/null
+++ b/projects/igniteui-angular/chips/src/chips/chips-area.component.ts
@@ -0,0 +1,373 @@
+import { Component, ContentChildren, ChangeDetectorRef, EventEmitter, HostBinding, Input, IterableDiffer, IterableDiffers, Output, QueryList, DoCheck, AfterViewInit, OnDestroy, ElementRef, inject } from '@angular/core';
+import {
+ IgxChipComponent,
+ IChipSelectEventArgs,
+ IChipKeyDownEventArgs,
+ IChipEnterDragAreaEventArgs,
+ IBaseChipEventArgs
+} from './chip.component';
+import { IDropBaseEventArgs, IDragBaseEventArgs } from 'igniteui-angular/directives';
+import { takeUntil } from 'rxjs/operators';
+import { Subject } from 'rxjs';
+import { rem } from 'igniteui-angular/core';
+
+export interface IBaseChipsAreaEventArgs {
+ originalEvent: IDragBaseEventArgs | IDropBaseEventArgs | KeyboardEvent | MouseEvent | TouchEvent;
+ owner: IgxChipsAreaComponent;
+}
+
+export interface IChipsAreaReorderEventArgs extends IBaseChipsAreaEventArgs {
+ chipsArray: IgxChipComponent[];
+}
+
+export interface IChipsAreaSelectEventArgs extends IBaseChipsAreaEventArgs {
+ newSelection: IgxChipComponent[];
+}
+
+/**
+ * The chip area allows you to perform more complex scenarios with chips that require interaction,
+ * like dragging, selection, navigation, etc.
+ *
+ * @igxModule IgxChipsModule
+ *
+ * @igxTheme igx-chip-theme
+ *
+ * @igxKeywords chip area, chip
+ *
+ * @igxGroup display
+ *
+ * @example
+ * ```html
+ *
+ *
+ * {{chip.text}}
+ *
+ *
+ * ```
+ */
+@Component({
+ selector: 'igx-chips-area',
+ templateUrl: 'chips-area.component.html',
+ standalone: true
+})
+export class IgxChipsAreaComponent implements DoCheck, AfterViewInit, OnDestroy {
+ public cdr = inject(ChangeDetectorRef);
+ public element = inject(ElementRef);
+ private _iterableDiffers = inject(IterableDiffers);
+
+
+ /**
+ * Returns the `role` attribute of the chips area.
+ *
+ * @example
+ * ```typescript
+ * let chipsAreaRole = this.chipsArea.role;
+ * ```
+ */
+ @HostBinding('attr.role')
+ public role = 'listbox';
+
+ /**
+ * Returns the `aria-label` attribute of the chips area.
+ *
+ * @example
+ * ```typescript
+ * let ariaLabel = this.chipsArea.ariaLabel;
+ * ```
+ *
+ */
+ @HostBinding('attr.aria-label')
+ public ariaLabel = 'chip area';
+
+ /**
+ * Sets the width of the `IgxChipsAreaComponent`.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @Input()
+ public width: number;
+
+ /** @hidden @internal */
+ @HostBinding('style.width.rem')
+ public get _widthToRem() {
+ return rem(this.width);
+ }
+
+ /**
+ * Sets the height of the `IgxChipsAreaComponent`.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @Input()
+ public height: number;
+
+ /** @hidden @internal */
+ @HostBinding('style.height.rem')
+ public get _heightToRem() {
+ return rem(this.height);
+ }
+
+ /**
+ * Emits an event when `IgxChipComponent`s in the `IgxChipsAreaComponent` should be reordered.
+ * Returns an array of `IgxChipComponent`s.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @Output()
+ public reorder = new EventEmitter();
+
+ /**
+ * Emits an event when an `IgxChipComponent` in the `IgxChipsAreaComponent` is selected/deselected.
+ * Fired after the chips area is initialized if there are initially selected chips as well.
+ * Returns an array of selected `IgxChipComponent`s and the `IgxChipAreaComponent`.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @Output()
+ public selectionChange = new EventEmitter();
+
+ /**
+ * Emits an event when an `IgxChipComponent` in the `IgxChipsAreaComponent` is moved.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @Output()
+ public moveStart = new EventEmitter();
+
+ /**
+ * Emits an event after an `IgxChipComponent` in the `IgxChipsAreaComponent` is moved.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @Output()
+ public moveEnd = new EventEmitter();
+
+ /**
+ * Holds the `IgxChipComponent` in the `IgxChipsAreaComponent`.
+ *
+ * @example
+ * ```typescript
+ * ngAfterViewInit(){
+ * let chips = this.chipsArea.chipsList;
+ * }
+ * ```
+ */
+ @ContentChildren(IgxChipComponent, { descendants: true })
+ public chipsList: QueryList;
+
+ protected destroy$ = new Subject();
+
+ @HostBinding('class')
+ protected hostClass = 'igx-chip-area';
+
+ private modifiedChipsArray: IgxChipComponent[];
+ private _differ: IterableDiffer | null = null;
+
+ constructor() {
+ this._differ = this._iterableDiffers.find([]).create(null);
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ public ngAfterViewInit() {
+ // If we have initially selected chips through their inputs, we need to get them, because we cannot listen to their events yet.
+ if (this.chipsList.length) {
+ const selectedChips = this.chipsList.filter((item: IgxChipComponent) => item.selected);
+ if (selectedChips.length) {
+ this.selectionChange.emit({
+ originalEvent: null,
+ newSelection: selectedChips,
+ owner: this
+ });
+ }
+ }
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ public ngDoCheck(): void {
+ if (this.chipsList) {
+ const changes = this._differ.diff(this.chipsList.toArray());
+ if (changes) {
+ changes.forEachAddedItem((addedChip) => {
+ addedChip.item.moveStart.pipe(takeUntil(addedChip.item.destroy$)).subscribe((args) => {
+ this.onChipMoveStart(args);
+ });
+ addedChip.item.moveEnd.pipe(takeUntil(addedChip.item.destroy$)).subscribe((args) => {
+ this.onChipMoveEnd(args);
+ });
+ addedChip.item.dragEnter.pipe(takeUntil(addedChip.item.destroy$)).subscribe((args) => {
+ this.onChipDragEnter(args);
+ });
+ addedChip.item.keyDown.pipe(takeUntil(addedChip.item.destroy$)).subscribe((args) => {
+ this.onChipKeyDown(args);
+ });
+ if (addedChip.item.selectable) {
+ addedChip.item.selectedChanging.pipe(takeUntil(addedChip.item.destroy$)).subscribe((args) => {
+ this.onChipSelectionChange(args);
+ });
+ }
+ });
+ this.modifiedChipsArray = this.chipsList.toArray();
+ }
+ }
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ public ngOnDestroy(): void {
+ this.destroy$.next(true);
+ this.destroy$.complete();
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ protected onChipKeyDown(event: IChipKeyDownEventArgs) {
+ let orderChanged = false;
+ const chipsArray = this.chipsList.toArray();
+ const dragChipIndex = chipsArray.findIndex((el) => el === event.owner);
+ if (event.originalEvent.shiftKey === true) {
+ if (event.originalEvent.key === 'ArrowLeft' || event.originalEvent.key === 'Left') {
+ orderChanged = this.positionChipAtIndex(dragChipIndex, dragChipIndex - 1, false, event.originalEvent);
+ if (orderChanged) {
+ setTimeout(() => {
+ this.chipsList.get(dragChipIndex - 1).nativeElement.focus();
+ });
+ }
+ } else if (event.originalEvent.key === 'ArrowRight' || event.originalEvent.key === 'Right') {
+ orderChanged = this.positionChipAtIndex(dragChipIndex, dragChipIndex + 1, true, event.originalEvent);
+ }
+ } else {
+ if ((event.originalEvent.key === 'ArrowLeft' || event.originalEvent.key === 'Left') && dragChipIndex > 0) {
+ chipsArray[dragChipIndex - 1].nativeElement.focus();
+ } else if ((event.originalEvent.key === 'ArrowRight' || event.originalEvent.key === 'Right') &&
+ dragChipIndex < chipsArray.length - 1) {
+ chipsArray[dragChipIndex + 1].nativeElement.focus();
+ }
+ }
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ protected onChipMoveStart(event: IBaseChipEventArgs) {
+ this.moveStart.emit({
+ originalEvent: event.originalEvent,
+ owner: this
+ });
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ protected onChipMoveEnd(event: IBaseChipEventArgs) {
+ this.moveEnd.emit({
+ originalEvent: event.originalEvent,
+ owner: this
+ });
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ protected onChipDragEnter(event: IChipEnterDragAreaEventArgs) {
+ const dropChipIndex = this.chipsList.toArray().findIndex((el) => el === event.owner);
+ const dragChipIndex = this.chipsList.toArray().findIndex((el) => el === event.dragChip);
+ if (dragChipIndex < dropChipIndex) {
+ // from the left to right
+ this.positionChipAtIndex(dragChipIndex, dropChipIndex, true, event.originalEvent);
+ } else {
+ // from the right to left
+ this.positionChipAtIndex(dragChipIndex, dropChipIndex, false, event.originalEvent);
+ }
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ protected positionChipAtIndex(chipIndex, targetIndex, shiftRestLeft, originalEvent) {
+ if (chipIndex < 0 || this.chipsList.length <= chipIndex ||
+ targetIndex < 0 || this.chipsList.length <= targetIndex) {
+ return false;
+ }
+
+ const chipsArray = this.chipsList.toArray();
+ const result: IgxChipComponent[] = [];
+ for (let i = 0; i < chipsArray.length; i++) {
+ if (shiftRestLeft) {
+ if (chipIndex <= i && i < targetIndex) {
+ result.push(chipsArray[i + 1]);
+ } else if (i === targetIndex) {
+ result.push(chipsArray[chipIndex]);
+ } else {
+ result.push(chipsArray[i]);
+ }
+ } else {
+ if (targetIndex < i && i <= chipIndex) {
+ result.push(chipsArray[i - 1]);
+ } else if (i === targetIndex) {
+ result.push(chipsArray[chipIndex]);
+ } else {
+ result.push(chipsArray[i]);
+ }
+ }
+ }
+ this.modifiedChipsArray = result;
+
+ const eventData: IChipsAreaReorderEventArgs = {
+ chipsArray: this.modifiedChipsArray,
+ originalEvent,
+ owner: this
+ };
+ this.reorder.emit(eventData);
+ return true;
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ protected onChipSelectionChange(event: IChipSelectEventArgs) {
+ let selectedChips = this.chipsList.filter((chip) => chip.selected);
+ if (event.selected && !selectedChips.includes(event.owner)) {
+ selectedChips.push(event.owner);
+ } else if (!event.selected && selectedChips.includes(event.owner)) {
+ selectedChips = selectedChips.filter((chip) => chip.id !== event.owner.id);
+ }
+ this.selectionChange.emit({
+ originalEvent: event.originalEvent,
+ newSelection: selectedChips,
+ owner: this
+ });
+ }
+}
diff --git a/projects/igniteui-angular/chips/src/chips/chips-area.spec.ts b/projects/igniteui-angular/chips/src/chips/chips-area.spec.ts
new file mode 100644
index 00000000000..75cd1090f56
--- /dev/null
+++ b/projects/igniteui-angular/chips/src/chips/chips-area.spec.ts
@@ -0,0 +1,650 @@
+import { Component, ViewChild, ViewChildren, QueryList, ChangeDetectorRef, inject } from '@angular/core';
+import { TestBed, waitForAsync } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { IgxChipComponent } from './chip.component';
+import { IgxChipsAreaComponent } from './chips-area.component';
+import { IgxIconComponent } from 'igniteui-angular/icon';
+import { IgxPrefixDirective } from 'igniteui-angular/input-group';
+import { UIInteractions, wait } from 'igniteui-angular/test-utils/ui-interactions.spec';
+
+
+@Component({
+ template: `
+
+ @for (chip of chipList; track chip.id) {
+
+ drag_indicator
+ {{chip.text}}
+
+ }
+
+ `,
+ imports: [IgxChipsAreaComponent, IgxChipComponent, IgxIconComponent, IgxPrefixDirective]
+})
+class TestChipComponent {
+ public cdr = inject(ChangeDetectorRef);
+
+ @ViewChild('chipsArea', { read: IgxChipsAreaComponent, static: true })
+ public chipsArea: IgxChipsAreaComponent;
+
+ @ViewChildren('chipElem', { read: IgxChipComponent })
+ public chips: QueryList;
+
+ public chipList = [
+ { id: 'Country', text: 'Country', removable: false, selectable: false, draggable: false },
+ { id: 'City', text: 'City', removable: true, selectable: true, draggable: true }
+ ];
+}
+
+@Component({
+ template: `
+
+
+ first chip
+
+
+ second chip
+
+
+ third chip
+
+
+ `,
+ imports: [IgxChipsAreaComponent, IgxChipComponent]
+})
+class TestChipSelectComponent extends TestChipComponent {
+}
+
+@Component({
+ template: `
+
+ @for (chip of chipList; track chip.id) {
+
+ drag_indicator
+ {{chip.text}}
+
+ }
+
+ `,
+ imports: [IgxChipsAreaComponent, IgxChipComponent, IgxIconComponent, IgxPrefixDirective]
+})
+class TestChipReorderComponent {
+ public cdr = inject(ChangeDetectorRef);
+
+ @ViewChild('chipsArea', { read: IgxChipsAreaComponent, static: true })
+ public chipsArea: IgxChipsAreaComponent;
+
+ @ViewChildren('chipElem', { read: IgxChipComponent })
+ public chips: QueryList;
+
+ public chipList = [
+ { id: 'Country', text: 'Country' },
+ { id: 'City', text: 'City' },
+ { id: 'Town', text: 'Town' },
+ { id: 'FirstName', text: 'First Name' },
+ ];
+
+ public chipsOrderChanged(event) {
+ const newChipList = [];
+ for (const chip of event.chipsArray) {
+ newChipList.push(this.chipList.find((item) => item.id === chip.id));
+ }
+ this.chipList = newChipList;
+ }
+
+ public chipRemoved(event) {
+ this.chipList = this.chipList.filter((item) => item.id !== event.owner.id);
+ this.chipsArea.cdr.detectChanges();
+ }
+}
+
+
+describe('IgxChipsArea ', () => {
+ const CHIP_REMOVE_BUTTON = 'igx-chip__remove';
+ const CHIP_AREA_CLASS = 'igx-chip-area';
+
+ let fix;
+ let chipArea: IgxChipsAreaComponent;
+ let chipAreaElement;
+
+ beforeEach(waitForAsync(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ TestChipComponent,
+ TestChipReorderComponent,
+ TestChipSelectComponent
+ ]
+ }).compileComponents();
+ }));
+
+ describe('Basic', () => {
+ beforeEach(() => {
+ fix = TestBed.createComponent(TestChipComponent);
+ fix.detectChanges();
+ chipAreaElement = fix.debugElement.query(By.directive(IgxChipsAreaComponent));
+ });
+
+ it('should add chips when adding data items ', () => {
+ expect(chipAreaElement.nativeElement.classList).toEqual(jasmine.arrayWithExactContents(['customClass', CHIP_AREA_CLASS]));
+ expect(chipAreaElement.nativeElement.children.length).toEqual(2);
+
+ fix.componentInstance.chipList.push({ id: 'Town', text: 'Town', removable: true, selectable: true, draggable: true });
+
+ fix.detectChanges();
+
+ expect(chipAreaElement.nativeElement.children.length).toEqual(3);
+ });
+
+ it('should remove chips when removing data items ', () => {
+ expect(chipAreaElement.nativeElement.children.length).toEqual(2);
+
+ fix.componentInstance.chipList.pop();
+ fix.detectChanges();
+
+ expect(chipAreaElement.nativeElement.children.length).toEqual(1);
+ });
+
+ it('should change data in chips when data item is changed', () => {
+ expect(chipAreaElement.nativeElement.children[0].innerHTML).toContain('Country');
+
+ fix.componentInstance.chipList[0].text = 'New text';
+ fix.detectChanges();
+
+ expect(chipAreaElement.nativeElement.children[0].innerHTML).toContain('New text');
+ });
+ });
+
+
+ describe('Selection', () => {
+ const spaceKeyEvent = new KeyboardEvent('keydown', { key: ' ' });
+
+ it('should be able to select chip using input property', () => {
+ fix = TestBed.createComponent(TestChipSelectComponent);
+ fix.detectChanges();
+
+ const firstChipComp = fix.componentInstance.chips.toArray()[0];
+ const secondChipComp = fix.componentInstance.chips.toArray()[1];
+ const thirdChipComp = fix.componentInstance.chips.toArray()[2];
+
+ expect(firstChipComp.selected).toBe(true);
+ expect(secondChipComp.selected).toBe(true);
+ expect(thirdChipComp.selected).toBe(false);
+ });
+
+ it('should emit selectionChange for the chipArea event when there are initially selected chips through their inputs', () => {
+ fix = TestBed.createComponent(TestChipSelectComponent);
+ chipArea = fix.componentInstance.chipsArea;
+
+ spyOn(chipArea.selectionChange, 'emit');
+
+ fix.detectChanges();
+
+ const chipComponents = fix.componentInstance.chips.toArray();
+ expect(chipArea.selectionChange.emit).toHaveBeenCalledWith({
+ originalEvent: null,
+ owner: chipArea,
+ newSelection: [chipComponents[0], chipComponents[1]]
+ });
+ });
+
+ it('should focus on chip correctly', () => {
+ fix = TestBed.createComponent(TestChipSelectComponent);
+ fix.detectChanges();
+
+ const firstChipComp = fix.componentInstance.chips.toArray()[0];
+ const secondChipComp = fix.componentInstance.chips.toArray()[1];
+
+ firstChipComp.nativeElement.focus();
+ expect(document.activeElement).toBe(firstChipComp.nativeElement);
+
+ secondChipComp.nativeElement.focus();
+ expect(document.activeElement).toBe(secondChipComp.nativeElement);
+ });
+
+ it('should focus on previous and next chips after arrows are pressed', () => {
+ fix = TestBed.createComponent(TestChipSelectComponent);
+ fix.detectChanges();
+
+ const firstChipComp = fix.componentInstance.chips.toArray()[0];
+ const secondChipComp = fix.componentInstance.chips.toArray()[1];
+
+ firstChipComp.nativeElement.focus();
+ fix.detectChanges();
+
+ expect(document.activeElement).toBe(firstChipComp.nativeElement);
+
+ const rightKey = new KeyboardEvent('keydown', { key: 'ArrowRight' });
+ firstChipComp.onChipKeyDown(rightKey);
+ fix.detectChanges();
+
+ expect(document.activeElement).toBe(secondChipComp.nativeElement);
+
+ const leftKey = new KeyboardEvent('keydown', { key: 'ArrowLeft' });
+ secondChipComp.onChipKeyDown(leftKey);
+ fix.detectChanges();
+
+ expect(document.activeElement).toBe(firstChipComp.nativeElement);
+ });
+
+ it('should fire selectionChange event', () => {
+ fix = TestBed.createComponent(TestChipComponent);
+ fix.detectChanges();
+ fix.componentInstance.cdr.detectChanges();
+
+ const secondChipComp: IgxChipComponent = fix.componentInstance.chips.toArray()[1];
+ const chipAreaComp: IgxChipsAreaComponent = fix.debugElement.query(By.directive(IgxChipsAreaComponent)).componentInstance;
+ spyOn(chipAreaComp.selectionChange, 'emit');
+
+ secondChipComp.onChipKeyDown(spaceKeyEvent);
+ fix.detectChanges();
+
+ expect(chipAreaComp.selectionChange.emit).toHaveBeenCalledWith({
+ originalEvent: spaceKeyEvent,
+ owner: chipAreaComp,
+ newSelection: [secondChipComp]
+ });
+
+ let chipsSelectionStates = fix.componentInstance.chips.toArray().filter(c => c.selected);
+ expect(chipsSelectionStates.length).toEqual(1);
+ expect(secondChipComp.selected).toBeTruthy();
+
+ secondChipComp.onChipKeyDown(spaceKeyEvent);
+ fix.detectChanges();
+
+ expect(chipAreaComp.selectionChange.emit).toHaveBeenCalledWith({
+ originalEvent: spaceKeyEvent,
+ owner: chipAreaComp,
+ newSelection: []
+ });
+
+ chipsSelectionStates = fix.componentInstance.chips.toArray().filter(c => c.selected);
+ expect(chipsSelectionStates.length).toEqual(0);
+ expect(secondChipComp.selected).not.toBeTruthy();
+ });
+
+ it('should be able to have multiple chips selected', () => {
+ fix = TestBed.createComponent(TestChipComponent);
+ fix.detectChanges();
+
+ const chipAreaComponent = fix.componentInstance;
+
+ chipAreaComponent.chipList.push({ id: 'Town', text: 'Town', removable: true, selectable: true, draggable: true });
+ fix.detectChanges();
+
+ spyOn(chipAreaComponent.chipsArea.selectionChange, `emit`);
+ chipAreaComponent.chipsArea.chipsList.toArray()[1].selected = true;
+ fix.detectChanges();
+ chipAreaComponent.chipsArea.chipsList.toArray()[2].selected = true;
+ fix.detectChanges();
+
+ const secondChipComp = fix.componentInstance.chips.toArray()[1];
+ const thirdChipComp = fix.componentInstance.chips.toArray()[2];
+ expect(chipAreaComponent.chipsArea.selectionChange.emit).toHaveBeenCalledTimes(2);
+ expect(chipAreaComponent.chipsArea.selectionChange.emit).toHaveBeenCalledWith({
+ originalEvent: null,
+ owner: chipAreaComponent.chipsArea,
+ newSelection: [secondChipComp, thirdChipComp]
+ });
+ });
+
+ it('should be able to select chip using input property', () => {
+ fix = TestBed.createComponent(TestChipSelectComponent);
+ fix.detectChanges();
+
+ const firstChipComp = fix.componentInstance.chips.toArray()[0];
+ const secondChipComp = fix.componentInstance.chips.toArray()[1];
+ const thirdChipComp = fix.componentInstance.chips.toArray()[2];
+
+ expect(firstChipComp.selected).toBe(true);
+ expect(secondChipComp.selected).toBe(true);
+ expect(thirdChipComp.selected).toBe(false);
+ });
+
+ it('should emit onSelection for the chipArea event when there are initially selected chips through their inputs', () => {
+ fix = TestBed.createComponent(TestChipSelectComponent);
+ chipArea = fix.componentInstance.chipsArea;
+
+ spyOn(chipArea.selectionChange, 'emit');
+
+ fix.detectChanges();
+
+ const chipComponents = fix.componentInstance.chips.toArray();
+ expect(chipArea.selectionChange.emit).toHaveBeenCalledWith({
+ originalEvent: null,
+ owner: chipArea,
+ newSelection: [chipComponents[0], chipComponents[1]]
+ });
+ });
+
+ it('should be able to select chip using api when selectable is set to false', () => {
+ fix = TestBed.createComponent(TestChipComponent);
+ fix.detectChanges();
+
+ const igxChip = fix.componentInstance.chipsArea.chipsList.toArray()[0];
+ const igxChipItem = igxChip.nativeElement.children[0]; // Return igx-chip__item
+
+ igxChip.selected = true;
+ fix.detectChanges();
+
+ expect(igxChip.selected).toBe(true);
+ expect(igxChipItem).toHaveClass(`igx-chip__item--selected`);
+ });
+
+ it('should fire only onSelection event for chip area when selecting a chip using spacebar', () => {
+ fix = TestBed.createComponent(TestChipComponent);
+ fix.detectChanges();
+ fix.componentInstance.cdr.detectChanges();
+
+ chipArea = fix.componentInstance.chipsArea;
+ const secondChip = fix.componentInstance.chips.toArray()[1];
+
+ spyOn(chipArea.reorder, 'emit');
+ spyOn(chipArea.selectionChange, 'emit');
+ spyOn(chipArea.moveStart, 'emit');
+ spyOn(chipArea.moveEnd, 'emit');
+
+
+ secondChip.onChipKeyDown(spaceKeyEvent);
+ fix.detectChanges();
+
+ expect(chipArea.selectionChange.emit).toHaveBeenCalled();
+ expect(chipArea.reorder.emit).not.toHaveBeenCalled();
+ expect(chipArea.moveStart.emit).not.toHaveBeenCalled();
+ expect(chipArea.moveEnd.emit).not.toHaveBeenCalled();
+ });
+
+ it('should select a chip by clicking on it and emit onSelection event', () => {
+ fix = TestBed.createComponent(TestChipComponent);
+ fix.detectChanges();
+
+ chipArea = fix.componentInstance.chipsArea;
+ const secondChip = fix.componentInstance.chips.toArray()[1];
+ const pointerUpEvt = new PointerEvent('pointerup');
+
+ spyOn(chipArea.selectionChange, 'emit');
+ fix.detectChanges();
+
+ secondChip.onChipDragClicked({
+ originalEvent: pointerUpEvt,
+ owner: secondChip.dragDirective,
+ pageX: 0, pageY: 0, startX: 0, startY: 0
+ });
+ fix.detectChanges();
+
+ expect(chipArea.selectionChange.emit).toHaveBeenCalled();
+ expect(chipArea.selectionChange.emit).not.toHaveBeenCalledWith({
+ originalEvent: pointerUpEvt,
+ owner: chipArea,
+ newSelection: [secondChip]
+ });
+ });
+
+ it('should persist selected state when it is dragged and dropped', () => {
+ fix = TestBed.createComponent(TestChipComponent);
+ fix.detectChanges();
+
+ chipAreaElement = fix.debugElement.query(By.directive(IgxChipsAreaComponent));
+ const chipComponents = chipAreaElement.queryAll(By.directive(IgxChipComponent));
+ const secondChip = chipComponents[1].componentInstance;
+
+ secondChip.animateOnRelease = false;
+ secondChip.onChipKeyDown(spaceKeyEvent);
+
+ expect(secondChip.selected).toBeTruthy();
+ UIInteractions.moveDragDirective(fix, secondChip.dragDirective, 200, 100);
+
+ const firstChip = chipComponents[0].componentInstance;
+ expect(firstChip.selected).not.toBeTruthy();
+ expect(secondChip.selected).toBeTruthy();
+ });
+ });
+
+ describe('Reorder', () => {
+ const leftKeyEvent = new KeyboardEvent('keydown', { key: 'ArrowLeft', shiftKey: true });
+ const rightKeyEvent = new KeyboardEvent('keydown', { key: 'ArrowRight', shiftKey: true });
+ const deleteKeyEvent = new KeyboardEvent('keydown', { key: 'Delete' });
+
+ beforeEach(() => {
+ fix = TestBed.createComponent(TestChipReorderComponent);
+ fix.detectChanges();
+ fix.componentInstance.cdr.detectChanges();
+ });
+
+ it('should reorder chips when shift + leftarrow and shift + rightarrow is pressed', () => {
+ const chipComponents = fix.debugElement.queryAll(By.directive(IgxChipComponent));
+ const firstChipAreaElem = chipComponents[0].componentInstance.nativeElement;
+ const secondChipAreaElem = chipComponents[1].componentInstance.nativeElement;
+ const firstChipLeft = firstChipAreaElem.getBoundingClientRect().left;
+ const secondChipLeft = secondChipAreaElem.getBoundingClientRect().left;
+
+ firstChipAreaElem.dispatchEvent(rightKeyEvent);
+ fix.detectChanges();
+
+ let newFirstChipLeft = firstChipAreaElem.getBoundingClientRect().left;
+ let newSecondChipLeft = secondChipAreaElem.getBoundingClientRect().left;
+ expect(firstChipLeft).toBeLessThan(newFirstChipLeft);
+ expect(newSecondChipLeft).toBeLessThan(secondChipLeft);
+
+ firstChipAreaElem.dispatchEvent(leftKeyEvent);
+ fix.detectChanges();
+
+ newFirstChipLeft = firstChipAreaElem.getBoundingClientRect().left;
+ newSecondChipLeft = secondChipAreaElem.getBoundingClientRect().left;
+
+ expect(firstChipLeft).toEqual(newFirstChipLeft);
+ expect(newSecondChipLeft).toEqual(secondChipLeft);
+ });
+
+ it('should reorder chips and keeps focus when Shift + Left Arrow is pressed and Shift + Right Arrow is pressed twice',
+ (async () => {
+ chipArea = fix.componentInstance.chipsArea;
+ const chipComponents = fix.debugElement.queryAll(By.directive(IgxChipComponent));
+ const targetChip = chipComponents[2].componentInstance;
+ const targetChipElem = targetChip.nativeElement;
+
+ targetChipElem.focus();
+ fix.detectChanges();
+
+ expect(document.activeElement).toBe(targetChipElem);
+ expect(chipArea.chipsList.toArray()[2].id).toEqual('Town');
+ expect(chipArea.chipsList.toArray()[3].id).toEqual('FirstName');
+
+ targetChip.onChipKeyDown(rightKeyEvent);
+ fix.detectChanges();
+
+ expect(document.activeElement).toBe(targetChipElem);
+ expect(chipArea.chipsList.toArray()[2].id).toEqual('FirstName');
+ expect(chipArea.chipsList.toArray()[3].id).toEqual('Town');
+
+ targetChip.onChipKeyDown(leftKeyEvent);
+ fix.detectChanges();
+ await wait();
+
+ expect(document.activeElement).toBe(targetChipElem);
+ expect(chipArea.chipsList.toArray()[2].id).toEqual('Town');
+ expect(chipArea.chipsList.toArray()[3].id).toEqual('FirstName');
+
+ targetChip.onChipKeyDown(leftKeyEvent);
+ fix.detectChanges();
+ await wait();
+
+ expect(document.activeElement).toBe(targetChipElem);
+ expect(chipArea.chipsList.toArray()[2].id).toEqual('City');
+ expect(chipArea.chipsList.toArray()[3].id).toEqual('FirstName');
+ })
+ );
+
+ it('should not reorder chips for shift + leftarrow when the chip is going out of bounds', () => {
+ const chipComponents = fix.debugElement.queryAll(By.directive(IgxChipComponent));
+
+ const firstChipAreaElem = chipComponents[0].componentInstance.chipArea.nativeElement;
+ const firstChipLeft = firstChipAreaElem.getBoundingClientRect().left;
+ firstChipAreaElem.dispatchEvent(leftKeyEvent);
+ fix.detectChanges();
+
+ const newFirstChipLeft = firstChipAreaElem.getBoundingClientRect().left;
+ expect(firstChipLeft).toEqual(newFirstChipLeft);
+ });
+
+ it('should not reorder chips for shift + rightarrow when the chip is going out of bounds', () => {
+ const chipComponents = fix.debugElement.queryAll(By.directive(IgxChipComponent));
+
+ const lastChipAreaElem = chipComponents[chipComponents.length - 1].componentInstance.chipArea.nativeElement;
+ const lastChipLeft = lastChipAreaElem.getBoundingClientRect().left;
+ lastChipAreaElem.dispatchEvent(rightKeyEvent);
+ fix.detectChanges();
+
+ const newLastChipLeft = lastChipAreaElem.getBoundingClientRect().left;
+ expect(newLastChipLeft).toEqual(lastChipLeft);
+ });
+
+ it('should delete chip when delete key is pressed and chip is removable', () => {
+ let chipComponents = fix.debugElement.queryAll(By.directive(IgxChipComponent));
+
+ expect(chipComponents.length).toEqual(4);
+
+ const firstChipComp = chipComponents[0].componentInstance;
+ firstChipComp.onChipKeyDown(deleteKeyEvent);
+ fix.detectChanges();
+
+ chipComponents = fix.debugElement.queryAll(By.directive(IgxChipComponent));
+ expect(chipComponents.length).toEqual(3);
+ });
+
+ it('should delete chip when delete button is clicked', () => {
+ let chipComponents = fix.debugElement.queryAll(By.directive(IgxChipComponent));
+ expect(chipComponents.length).toEqual(4);
+
+ const deleteButtonElement = fix.debugElement.queryAll(By.css('.' + CHIP_REMOVE_BUTTON))[0];
+ deleteButtonElement.nativeElement.click();
+ fix.detectChanges();
+
+ chipComponents = fix.debugElement.queryAll(By.directive(IgxChipComponent));
+ expect(chipComponents.length).toEqual(3);
+ });
+
+ it('should not fire any event of the chip area when attempting deleting of a chip', () => {
+ chipArea = fix.componentInstance.chipsArea;
+ const secondChip = fix.componentInstance.chips.toArray()[1];
+
+ spyOn(chipArea.reorder, 'emit');
+ spyOn(chipArea.selectionChange, 'emit');
+ spyOn(chipArea.moveStart, 'emit');
+ spyOn(chipArea.moveEnd, 'emit');
+ spyOn(secondChip.remove, 'emit');
+
+ secondChip.onChipKeyDown(deleteKeyEvent);
+ fix.detectChanges();
+
+ expect(secondChip.remove.emit).toHaveBeenCalled();
+ expect(chipArea.reorder.emit).not.toHaveBeenCalled();
+ expect(chipArea.selectionChange.emit).not.toHaveBeenCalled();
+ expect(chipArea.moveStart.emit).not.toHaveBeenCalled();
+ expect(chipArea.moveEnd.emit).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Interaction', () => {
+ it('should not be able to drag and drop when chip is not draggable', () => {
+ fix = TestBed.createComponent(TestChipComponent);
+ fix.detectChanges();
+
+ chipAreaElement = fix.debugElement.query(By.directive(IgxChipsAreaComponent));
+ const chipComponents = chipAreaElement.queryAll(By.directive(IgxChipComponent));
+ const firstChip = chipComponents[0].componentInstance;
+
+ UIInteractions.moveDragDirective(fix, firstChip.dragDirective, 50, 50, false);
+
+ expect(firstChip.dragDirective.ghostElement).toBeUndefined();
+ });
+
+ it('should be able to drag when chip is draggable', () => {
+ fix = TestBed.createComponent(TestChipComponent);
+ fix.detectChanges();
+
+ chipAreaElement = fix.debugElement.query(By.directive(IgxChipsAreaComponent));
+ const chipComponents = chipAreaElement.queryAll(By.directive(IgxChipComponent));
+ const secondChip = chipComponents[1].componentInstance;
+ const secondChipElem = secondChip.chipArea.nativeElement;
+
+ const startingTop = secondChipElem.getBoundingClientRect().top;
+ const startingLeft = secondChipElem.getBoundingClientRect().left;
+
+ const xDragDifference = 200;
+ const yDragDifference = 100;
+ UIInteractions.moveDragDirective(fix, secondChip.dragDirective, xDragDifference, yDragDifference, false);
+
+ expect(secondChip.dragDirective.ghostElement).toBeTruthy();
+
+ const afterDragTop = secondChip.dragDirective.ghostElement.getBoundingClientRect().top;
+ const afterDragLeft = secondChip.dragDirective.ghostElement.getBoundingClientRect().left;
+ expect(afterDragTop - startingTop).toEqual(yDragDifference);
+ expect(afterDragLeft - startingLeft).toEqual(xDragDifference);
+ });
+
+ it('should fire correctly reorder event when element is dragged and dropped to the right', () => {
+ fix = TestBed.createComponent(TestChipReorderComponent);
+ fix.detectChanges();
+
+ chipAreaElement = fix.debugElement.query(By.directive(IgxChipsAreaComponent));
+
+ const chipComponents = chipAreaElement.queryAll(By.directive(IgxChipComponent));
+ const firstChip = chipComponents[0].componentInstance;
+ const secondChip = chipComponents[1].componentInstance;
+
+ firstChip.animateOnRelease = false;
+ secondChip.animateOnRelease = false;
+
+ const firstChipElem = firstChip.chipArea.nativeElement;
+ const secondChipElem = secondChip.chipArea.nativeElement;
+
+ const firstChipLeft = firstChipElem.getBoundingClientRect().left;
+ UIInteractions.moveDragDirective(fix, firstChip.dragDirective, 100, 0);
+
+ const afterDropSecondChipLeft = secondChipElem.getBoundingClientRect().left;
+ expect(afterDropSecondChipLeft).toEqual(firstChipLeft);
+
+ const afterDropFirstChipLeft = firstChipElem.getBoundingClientRect().left;
+ expect(afterDropFirstChipLeft).not.toEqual(firstChipLeft);
+ });
+
+ it('should fire correctly reorder event when element is dragged and dropped to the left', () => {
+ fix = TestBed.createComponent(TestChipReorderComponent);
+ fix.detectChanges();
+
+ chipAreaElement = fix.debugElement.query(By.directive(IgxChipsAreaComponent));
+ const chipComponents = chipAreaElement.queryAll(By.directive(IgxChipComponent));
+ const firstChip = chipComponents[0].componentInstance;
+ const secondChip = chipComponents[1].componentInstance;
+
+ firstChip.animateOnRelease = false;
+ secondChip.animateOnRelease = false;
+
+ const firstChipElem = firstChip.chipArea.nativeElement;
+ const secondChipElem = secondChip.chipArea.nativeElement;
+
+ const firstChipLeft = firstChipElem.getBoundingClientRect().left;
+ UIInteractions.moveDragDirective(fix, secondChip.dragDirective, -100, 0);
+
+ const afterDropSecondChipLeft = secondChipElem.getBoundingClientRect().left;
+ expect(afterDropSecondChipLeft).toEqual(firstChipLeft);
+
+ const afterDropFirstChipLeft = firstChipElem.getBoundingClientRect().left;
+ expect(afterDropFirstChipLeft).not.toEqual(firstChipLeft);
+ });
+
+ it('should fire chipClick event', () => {
+ fix = TestBed.createComponent(TestChipComponent);
+ fix.detectChanges();
+
+ const firstChipComp: IgxChipComponent = fix.componentInstance.chips.toArray()[1];
+ spyOn(firstChipComp.chipClick, 'emit');
+
+ UIInteractions.clickDragDirective(fix, firstChipComp.dragDirective);
+
+ expect(firstChipComp.chipClick.emit).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/projects/igniteui-angular/chips/src/chips/chips.module.ts b/projects/igniteui-angular/chips/src/chips/chips.module.ts
new file mode 100644
index 00000000000..88e0c339e9a
--- /dev/null
+++ b/projects/igniteui-angular/chips/src/chips/chips.module.ts
@@ -0,0 +1,16 @@
+import { NgModule } from '@angular/core';
+import { IGX_CHIPS_DIRECTIVES } from './public_api';
+
+/**
+ * @hidden
+ * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components
+ */
+@NgModule({
+ exports: [
+ ...IGX_CHIPS_DIRECTIVES
+ ],
+ imports: [
+ ...IGX_CHIPS_DIRECTIVES
+ ]
+})
+export class IgxChipsModule { }
diff --git a/projects/igniteui-angular/chips/src/chips/public_api.ts b/projects/igniteui-angular/chips/src/chips/public_api.ts
new file mode 100644
index 00000000000..c09118da6ae
--- /dev/null
+++ b/projects/igniteui-angular/chips/src/chips/public_api.ts
@@ -0,0 +1,14 @@
+import { IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group';
+import { IgxChipComponent } from './chip.component';
+import { IgxChipsAreaComponent } from './chips-area.component';
+
+export * from './chip.component';
+export * from './chips-area.component';
+
+/* NOTE: Chips directives collection for ease-of-use import in standalone components scenario */
+export const IGX_CHIPS_DIRECTIVES = [
+ IgxChipsAreaComponent,
+ IgxChipComponent,
+ IgxPrefixDirective,
+ IgxSuffixDirective
+] as const;
diff --git a/projects/igniteui-angular/chips/src/public_api.ts b/projects/igniteui-angular/chips/src/public_api.ts
new file mode 100644
index 00000000000..5e4fa766e79
--- /dev/null
+++ b/projects/igniteui-angular/chips/src/public_api.ts
@@ -0,0 +1,2 @@
+export * from './chips/public_api';
+export * from './chips/chips.module';
diff --git a/projects/igniteui-angular/combo/README.md b/projects/igniteui-angular/combo/README.md
new file mode 100644
index 00000000000..d98765fcc4c
--- /dev/null
+++ b/projects/igniteui-angular/combo/README.md
@@ -0,0 +1,354 @@
+# igx-combo
+The igx-combo component provides a powerful input, combining the features of the basic HTML input, select and the IgniteUI for Angular igx-drop-down components.
+The combo component provides easy filtering and selection of multiple items, grouping and adding custom values to the dropdown list.
+Custom templates could be provided in order to customize different areas of the components, such as items, header, footer, etc.
+The combo component is integrated with the Template Driven and Reactive Forms.
+The igx-combo exposes intuitive keyboard navigation and it is accessibility compliant.
+Drop Down items are virtualized, which guarantees smooth work, even if the igx-combo is bound to data source with a lot of items.
+
+
+`igx-combo` is a component.
+A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/combo.html)
+
+# Usage
+Basic usage of `igx-combo` bound to a local data source, defining `valueKey` and `displayKey`:
+
+```html
+
+```
+
+Remote binding, defining `valueKey` and `displayKey`, and exposing `dataPreLoad` that allows to load new chunk of remote data to the combo (see the sample above as a reference):
+
+```html
+
+```
+
+```typescript
+public ngOnInit(): void {
+ this.remoteData = this.remoteService.remoteData;
+}
+
+public ngAfterViewInit(): void {
+ this.remoteService.getData(this.combo.virtualizationState, (data) => {
+ this.combo.totalItemCount = data.length;
+ });
+}
+
+public dataLoading(evt): void {
+ if (this.prevRequest) {
+ this.prevRequest.unsubscribe();
+ }
+
+ this.prevRequest = this.remoteService.getData(this.combo.virtualizationState, () => {
+ this.cdr.detectChanges();
+ this.combo.triggerCheck();
+ });
+ }
+```
+
+> Note: In order to have combo with remote data, what you need is to have a service that retrieves data chunks from a server.
+What the combo exposes is a `virtualizationState` property that gives state of the combo - first index and the number of items that needs to be loaded.
+The service, should inform the combo for the total items that are on the server - using the `totalItemCount` property.
+
+
+## Features
+
+### Selection
+
+Combo selection depends on the `[valueKey]` input property:
+
+- If a `[valueKey]` is specified, **all** methods and events tied to the selection operate w/ the value key property of the combo's `[data]` items:
+```html
+
+```
+```typescript
+export class MyCombo {
+ ...
+ public combo: IgxComboComponent;
+ public myCustomData: { id: number, text: string } = [{ id: 0, name: "One" }, ...];
+ ...
+ public ngOnInit(): void {
+ // Selection is done only by valueKey property value
+ this.combo.select([0, 1]);
+ }
+}
+```
+
+- When **no** `valueKey` is specified, selection is handled by **equality (===)**. To select items by object reference, the `valueKey` property should be removed:
+```html
+
+```
+```typescript
+export class MyCombo {
+ public ngOnInit(): void {
+ this.combo.select([this.data[0], this.data[1]]);
+ }
+}
+```
+
+### Value Binding
+
+If we want to use a two-way data-binding, we could just use `ngModel` like this:
+
+```html
+
+```
+```typescript
+export class MyExampleComponent {
+ ...
+ public data: {text: string, id: number, ... }[] = ...;
+ ...
+ public values: number[] = ...;
+}
+```
+
+When the `data` input is made up of complex types (i.e. objects), it is advised to bind the selected data via `valueKey` (as in the above code snippet). Specify a property that is unique for each data entry and pass an array with values for those properties, corresponding to the items you want selected.
+
+If you want to bind the selected data by reference, **do not** specify a `valueKey`:
+
+```html
+
+```
+```typescript
+export class MyExampleComponent {
+ ...
+ public data: {text: string, id: number, ... }[] = ...;
+ ...
+ public values: {text: string, id: number, ...} [] = [this.items[0], this.items[5]];
+}
+```
+
+
+
+### Filtering
+By default filtering in the combo is enabled. However you can disable it using the following code:
+
+```html
+
+```
+
+You can enable search case sensitivity by setting the `showSearchCaseIcon` property to true
+
+```html
+
+```
+
+
+
+
+
+### Custom Values
+Enabling the custom values will add missing from the list, using the combo's interface.
+
+```html
+
+```
+
+
+
+### Disabled
+You can disable combo using the following code:
+
+```html
+
+```
+
+
+
+### Grouping
+Defining a combo's groupKey option will group the items, according to that key.
+
+```html
+
+```
+
+
+
+### Templates
+Templates for different parts of the control can be defined, including items, header and footer, etc.
+When defining one of them, you need to reference list of predefined names, as follows:
+
+#### Defining item template:
+```html
+
+
+
+ State: {{ display[key] }}
+ Region: {{ display.region }}
+
+
+
+```
+
+#### Defining group headers template:
+
+```html
+
+
+
+ Header for {{ headerItem[key] }}
+
+
+
+```
+
+#### Defining header template:
+
+```html
+
+
+ Custom header
+
+
+
+```
+
+#### Defining footer template:
+
+```html
+
+
+
+
+
+
+```
+
+#### Defining empty template:
+
+```html
+
+
+ List is empty
+
+
+```
+
+#### Defining add template:
+
+```html
+
+
+ Add town
+
+
+```
+
+#### Defining toggle icon template:
+
+```html
+
+
+ {{ collapsed ? 'remove_circle' : 'remove_circle_outline'}}
+
+
+```
+
+#### Defining toggle icon template:
+
+```html
+
+
+ clear
+
+
+```
+
+
+
+## Keyboard Navigation
+
+When igxCombo is closed and focused:
+- `ArrowDown` or `Alt` + `ArrowDown` will open the combo drop down and will move focus to the search input.
+
+When igxCombo is opened and search input is focused:
+- `ArrowUp` or `Alt` + `ArrowUp` will close the combo drop down and will move focus to the closed combo.
+- `ArrowDown` will move focus from the search input to the first list item.If list is empty and custom values are enabled will move it to the Add new item button.
+ > Note: Any other key stroke will be handled by the input.
+
+When igxCombo is opened and list item is focused:
+- `ArrowDown` will move to next list item. If the active item is the last one in the list and custom values are enabled then focus will be moved to the Add item button.
+
+- `ArrowUp` will move to previous list item. If the active item is the first one in the list then focus will be moved back to the search input.
+
+- `End` will move to last list item.
+
+- `Home` will move to first list item.
+
+- `Space` will select/deselect active list item.
+
+- `Enter` will confirm the already selected items and will close the list.
+
+- `Esc` will close the list.
+
+When igxCombo is opened, allow custom values are enabled and add item button is focused:
+
+- `Enter` will add new item with valueKey and displayKey equal to the text in the search input and will select the new item.
+
+- `ArrowUp` focus will be moved back to the last list item or if list is empty will be moved to the search input.
+
+## API
+
+### Inputs
+
+| Name | Description | Type |
+|-----------------------|---------------------------------------------------|-----------------------------|
+| `id` | combo id | string |
+| `data` | combo data source | any[] |
+| `allowCustomValue` | enables/disables combo custom value | boolean |
+| `filterable` | enables/disables combo drop down filtering - enabled by default | boolean |
+| `showSearchCaseIcon` | defines whether the search case-sensitive icon should be displayed - disabled by default | boolean |
+| `valueKey` | combo value data source property | string |
+| `displayKey` | combo display data source property | string |
+| `groupKey` | combo item group | string |
+| `virtualizationState` | defines the current state of the virtualized data. It contains `startIndex` and `chunkSize` | `IForOfState` |
+| `totalItemCount` | total count of the virtual data items, when using remote service | number |
+| `width ` | defines combo width | string |
+| `itemsMaxHeight ` | defines drop down maximum height | number |
+| `itemsWidth ` | defines drop down width | string |
+| `itemHeight ` | defines drop down item height | number |
+| `placeholder ` | defines the "empty value" text | string |
+| `searchPlaceholder ` | defines the placeholder text for search input | string |
+| `collapsed` | gets drop down state | boolean |
+| `disabled` | defines whether the control is active or not | boolean |
+| `ariaLabelledBy` | defines label ID related to combo | boolean |
+| `type` | Combo style. - "line", "box", "border", "search" | string |
+| `valid` | gets if control is valid, when used in a form | boolean |
+| `overlaySettings` | gets/sets the custom overlay settings that control how the drop-down list displays | OverlaySettings |
+| `autoFocusSearch` | controls whether the search input should be focused when the combo is opened | boolean |
+| `filteringOptions` | Configures the way combo items will be filtered | IComboFilteringOptions |
+| `filterFunction` | Gets/Sets the custom filtering function of the combo | `(collection: any[], searchValue: any, caseSensitive: boolean) => any[]` |
+
+### Getters
+| Name | Description | Type |
+|--------------------------|---------------------------------------------------|-----------------------------|
+| `displayValue` | the value of the combo text field | string |
+| `value` | the value of the combo | any[] |
+| `selection` | the selected items of the combo | any[] |
+
+### Outputs
+
+| Name | Description | Cancelable | Emitted with |
+|---------------------|-------------------------------------------------------------------------|--------------|-----------------------------------|
+| `selectionChanging` | Emitted when item selection is changing, before the selection completes | true | `IComboSelectionChangingEventArgs` |
+| `searchInputUpdate` | Emitted when an the search input's input event is triggered | true | `IComboSearchInputEventArgs` |
+| `addition` | Emitted when an item is being added to the data collection | true | `IComboItemAdditionEvent` |
+| `dataPreLoad` | Emitted when new chunk of data is loaded from the virtualization | false | `IForOfState` |
+| `opening` | Emitted before the dropdown is opened | false | `IBaseCancelableBrowserEventArgs` |
+| `opened` | Emitted after the dropdown is opened | false | `IBaseEventArgs` |
+| `closing` | Emitted before the dropdown is closed | false | `IBaseCancelableBrowserEventArgs` |
+| `closed` | Emitted after the dropdown is closed | false | `IBaseEventArgs` |
+
+### Methods
+
+| Name | Description | Return type | Parameters |
+|--------------------|------------------------------------------|-------------|---------------------------------------------------------------|
+| `open` | Opens drop down | `void` | `None` |
+| `close` | Closes drop down | `void` | `None` |
+| `toggle` | Toggles drop down | `void` | `None` |
+| `selectedItems` | Get current selection state | `any[]` | `None` |
+| `select` | Select defined items | `void` | items: `any[]`, clearCurrentSelection: `boolean` |
+| `deselect` | Deselect defined items | `void` | items: `any[]` |
+| `selectAllItems` | Select all (filtered) items | `void` | ignoreFilter?: `boolean` - if `true` selects **all** values |
+| `deselectAllItems` | Deselect (filtered) all items | `void` | ignoreFilter?: `boolean` - if `true` deselects **all** values |
+| `selected` | Toggles (select/deselect) an item by key | `void` | itemID: any, select = true, event?: Event |
diff --git a/projects/igniteui-angular/combo/index.ts b/projects/igniteui-angular/combo/index.ts
new file mode 100644
index 00000000000..decc72d85bc
--- /dev/null
+++ b/projects/igniteui-angular/combo/index.ts
@@ -0,0 +1 @@
+export * from './src/public_api';
diff --git a/projects/igniteui-angular/combo/ng-package.json b/projects/igniteui-angular/combo/ng-package.json
new file mode 100644
index 00000000000..2c63c085104
--- /dev/null
+++ b/projects/igniteui-angular/combo/ng-package.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/projects/igniteui-angular/combo/src/combo/combo-add-item.component.ts b/projects/igniteui-angular/combo/src/combo/combo-add-item.component.ts
new file mode 100644
index 00000000000..cad1b5cf927
--- /dev/null
+++ b/projects/igniteui-angular/combo/src/combo/combo-add-item.component.ts
@@ -0,0 +1,28 @@
+import { IgxComboItemComponent } from './combo-item.component';
+import { Component, HostBinding } from '@angular/core';
+
+/**
+ * @hidden
+ */
+@Component({
+ selector: 'igx-combo-add-item',
+ template: ' ',
+ providers: [{ provide: IgxComboItemComponent, useExisting: IgxComboAddItemComponent }],
+})
+export class IgxComboAddItemComponent extends IgxComboItemComponent {
+ @HostBinding('class.igx-drop-down__item')
+ public get isDropDownItem(): boolean {
+ return false;
+ }
+
+ public override get selected(): boolean {
+ return false;
+ }
+ public override set selected(value: boolean) {
+ }
+
+ public override clicked(event?) {// eslint-disable-line
+ this.comboAPI.disableTransitions = false;
+ this.comboAPI.add_custom_item();
+ }
+}
diff --git a/projects/igniteui-angular/combo/src/combo/combo-dropdown.component.ts b/projects/igniteui-angular/combo/src/combo/combo-dropdown.component.ts
new file mode 100644
index 00000000000..8cdbaf022ae
--- /dev/null
+++ b/projects/igniteui-angular/combo/src/combo/combo-dropdown.component.ts
@@ -0,0 +1,219 @@
+import { Component, QueryList, OnDestroy, AfterViewInit, ContentChildren, Input, booleanAttribute, inject } from '@angular/core';
+import { IgxComboBase, IGX_COMBO_COMPONENT } from './combo.common';
+import { IgxComboAddItemComponent } from './combo-add-item.component';
+import { IgxComboAPIService } from './combo.api';
+import { IgxComboItemComponent } from './combo-item.component';
+import { IgxToggleDirective } from 'igniteui-angular/directives';
+import { DropDownActionKey, IDropDownBase, IGX_DROPDOWN_BASE, IgxDropDownComponent, IgxDropDownItemBaseDirective } from 'igniteui-angular/drop-down';
+
+/** @hidden */
+@Component({
+ selector: 'igx-combo-drop-down',
+ templateUrl: '../../../drop-down/src/drop-down/drop-down.component.html',
+ providers: [{ provide: IGX_DROPDOWN_BASE, useExisting: IgxComboDropDownComponent }],
+ imports: [IgxToggleDirective]
+})
+export class IgxComboDropDownComponent extends IgxDropDownComponent implements IDropDownBase, OnDestroy, AfterViewInit {
+ public combo = inject(IGX_COMBO_COMPONENT);
+ protected comboAPI = inject(IgxComboAPIService);
+
+ /** @hidden @internal */
+ @Input({ transform: booleanAttribute })
+ public singleMode = false;
+
+ /**
+ * @hidden
+ * @internal
+ */
+ @ContentChildren(IgxComboItemComponent, { descendants: true })
+ public override children: QueryList = null;
+
+ /** @hidden @internal */
+ public override get scrollContainer(): HTMLElement {
+ // TODO: Update, use public API if possible:
+ return this.virtDir.dc.location.nativeElement;
+ }
+
+ protected get isScrolledToLast(): boolean {
+ const scrollTop = this.virtDir.scrollPosition;
+ const scrollHeight = this.virtDir.getScroll().scrollHeight;
+ return Math.floor(scrollTop + this.virtDir.igxForContainerSize) === scrollHeight;
+ }
+
+ protected get lastVisibleIndex(): number {
+ return this.combo.totalItemCount ?
+ Math.floor(this.combo.itemsMaxHeight / this.combo.itemHeight) :
+ this.items.length - 1;
+ }
+
+ protected get sortedChildren(): IgxDropDownItemBaseDirective[] {
+ if (this.children !== undefined) {
+ return this.children.toArray()
+ .sort((a: IgxDropDownItemBaseDirective, b: IgxDropDownItemBaseDirective) => a.index - b.index);
+ }
+ return null;
+ }
+
+ /**
+ * Get all non-header items
+ *
+ * ```typescript
+ * let myDropDownItems = this.dropdown.items;
+ * ```
+ */
+ public override get items(): IgxComboItemComponent[] {
+ const items: IgxComboItemComponent[] = [];
+ if (this.children !== undefined) {
+ const sortedChildren = this.sortedChildren as IgxComboItemComponent[];
+ for (const child of sortedChildren) {
+ if (!child.isHeader) {
+ items.push(child);
+ }
+ }
+ }
+
+ return items;
+ }
+
+ /**
+ * @hidden @internal
+ */
+ public onFocus() {
+ this.focusedItem = this._focusedItem || this.items[0];
+ this.combo.setActiveDescendant();
+ }
+
+ /**
+ * @hidden @internal
+ */
+ public onBlur(_evt?) {
+ this.focusedItem = null;
+ this.combo.setActiveDescendant();
+ }
+
+ /**
+ * @hidden @internal
+ */
+ public override onToggleOpened() {
+ this.opened.emit();
+ }
+
+ /**
+ * @hidden
+ */
+ public override navigateFirst() {
+ this.navigateItem(this.virtDir.igxForOf.findIndex(e => !e?.isHeader));
+ this.combo.setActiveDescendant();
+ }
+
+ /**
+ * @hidden
+ */
+ public override navigatePrev() {
+ if (this._focusedItem && this._focusedItem.index === 0 && this.virtDir.state.startIndex === 0) {
+ this.combo.focusSearchInput(false);
+ this.focusedItem = null;
+ } else {
+ super.navigatePrev();
+ }
+ this.combo.setActiveDescendant();
+ }
+
+
+ /**
+ * @hidden
+ */
+ public override navigateNext() {
+ const lastIndex = this.combo.totalItemCount ? this.combo.totalItemCount - 1 : this.virtDir.igxForOf.length - 1;
+ if (this._focusedItem && this._focusedItem.index === lastIndex) {
+ this.focusAddItemButton();
+ } else {
+ super.navigateNext();
+ }
+ this.combo.setActiveDescendant();
+ }
+
+ /**
+ * @hidden @internal
+ */
+ public override selectItem(item: IgxDropDownItemBaseDirective) {
+ if (item === null || item === undefined) {
+ return;
+ }
+ this.comboAPI.set_selected_item(item.itemID);
+ this._focusedItem = item;
+ this.combo.setActiveDescendant();
+ }
+
+ /**
+ * @hidden @internal
+ */
+ public override updateScrollPosition() {
+ this.virtDir.getScroll().scrollTop = this._scrollPosition;
+ }
+
+ /**
+ * @hidden @internal
+ */
+ public override onItemActionKey(key: DropDownActionKey) {
+ switch (key) {
+ case DropDownActionKey.ENTER:
+ this.handleEnter();
+ break;
+ case DropDownActionKey.SPACE:
+ this.handleSpace();
+ break;
+ case DropDownActionKey.ESCAPE:
+ case DropDownActionKey.TAB:
+ this.close();
+ }
+ }
+
+ public override ngAfterViewInit() {
+ this.virtDir.getScroll().addEventListener('scroll', this.scrollHandler);
+ }
+
+ /**
+ * @hidden @internal
+ */
+ public override ngOnDestroy(): void {
+ this.virtDir.getScroll().removeEventListener('scroll', this.scrollHandler);
+ super.ngOnDestroy();
+ }
+
+ protected override scrollToHiddenItem(_newItem: any): void { }
+
+ protected scrollHandler = () => {
+ this.comboAPI.disableTransitions = true;
+ };
+
+ private handleEnter() {
+ if (this.isAddItemFocused()) {
+ this.combo.addItemToCollection();
+ return;
+ }
+ if (this.singleMode && this.focusedItem) {
+ this.combo.select(this.focusedItem.itemID);
+ }
+
+ this.close();
+ }
+
+ private handleSpace() {
+ if (this.isAddItemFocused()) {
+ return;
+ } else {
+ this.selectItem(this.focusedItem);
+ }
+ }
+
+ private isAddItemFocused(): boolean {
+ return this.focusedItem instanceof IgxComboAddItemComponent;
+ }
+
+ private focusAddItemButton() {
+ if (this.combo.isAddButtonVisible()) {
+ this.focusedItem = this.items[this.items.length - 1];
+ }
+ }
+}
diff --git a/projects/igniteui-angular/combo/src/combo/combo-item.component.html b/projects/igniteui-angular/combo/src/combo/combo-item.component.html
new file mode 100644
index 00000000000..b467360ae0d
--- /dev/null
+++ b/projects/igniteui-angular/combo/src/combo/combo-item.component.html
@@ -0,0 +1,5 @@
+@if (!isHeader && !singleMode) {
+
+
+}
+
diff --git a/projects/igniteui-angular/combo/src/combo/combo-item.component.ts b/projects/igniteui-angular/combo/src/combo/combo-item.component.ts
new file mode 100644
index 00000000000..bd6add67e1d
--- /dev/null
+++ b/projects/igniteui-angular/combo/src/combo/combo-item.component.ts
@@ -0,0 +1,120 @@
+import {
+ Component,
+ HostBinding,
+ Input,
+ booleanAttribute,
+ inject
+} from '@angular/core';
+import { IgxComboAPIService } from './combo.api';
+import { rem } from 'igniteui-angular/core';
+import { IgxCheckboxComponent } from 'igniteui-angular/checkbox';
+import { IgxDropDownItemComponent, Navigate } from 'igniteui-angular/drop-down';
+
+/** @hidden */
+@Component({
+ selector: 'igx-combo-item',
+ templateUrl: 'combo-item.component.html',
+ imports: [IgxCheckboxComponent]
+})
+export class IgxComboItemComponent extends IgxDropDownItemComponent {
+ protected comboAPI = inject(IgxComboAPIService);
+
+
+ /**
+ * Gets the height of a list item
+ *
+ * @hidden
+ */
+ @Input()
+ public itemHeight: string | number = '';
+
+ /** @hidden @internal */
+ @HostBinding('style.height.rem')
+ public get _itemHeightToRem() {
+ if (this.itemHeight) {
+ return rem(this.itemHeight);
+ }
+ }
+
+ @HostBinding('attr.aria-label')
+ @Input()
+ public override get ariaLabel(): string {
+ const valueKey = this.comboAPI.valueKey;
+ return (valueKey !== null && this.value != null) ? this.value[valueKey] : this.value;
+ }
+
+ /** @hidden @internal */
+ @Input({ transform: booleanAttribute })
+ public singleMode: boolean;
+
+ /**
+ * @hidden
+ */
+ public override get itemID() {
+ const valueKey = this.comboAPI.valueKey;
+ return valueKey !== null ? this.value[valueKey] : this.value;
+ }
+
+ /**
+ * @hidden
+ */
+ public get comboID() {
+ return this.comboAPI.comboID;
+ }
+
+ /**
+ * @hidden
+ * @internal
+ */
+ public get disableTransitions() {
+ return this.comboAPI.disableTransitions;
+ }
+
+ /**
+ * @hidden
+ */
+ public override get selected(): boolean {
+ return this.comboAPI.is_item_selected(this.itemID);
+ }
+
+ public override set selected(value: boolean) {
+ if (this.isHeader) {
+ return;
+ }
+ this._selected = value;
+ }
+
+ /**
+ * @hidden
+ */
+ public isVisible(direction: Navigate): boolean {
+ const rect = this.element.nativeElement.getBoundingClientRect();
+ const parentDiv = this.element.nativeElement.parentElement.parentElement.getBoundingClientRect();
+ if (direction === Navigate.Down) {
+ return rect.y + rect.height <= parentDiv.y + parentDiv.height;
+ }
+ return rect.y >= parentDiv.y;
+ }
+
+ public override clicked(event): void {
+ this.comboAPI.disableTransitions = false;
+ if (!this.isSelectable) {
+ return;
+ }
+ this.dropDown.navigateItem(this.index);
+ this.comboAPI.set_selected_item(this.itemID, event);
+ }
+
+ /**
+ * @hidden
+ * @internal
+ * The event that is prevented is the click on the checkbox label element.
+ * That is the only visible element that a user can interact with.
+ * The click propagates to the host and the preventDefault is to stop it from
+ * switching focus to the input it's base on.
+ * The toggle happens in an internal handler in the drop-down on the next task queue cycle.
+ */
+ public disableCheck(event: MouseEvent) {
+ event.preventDefault();
+ }
+}
diff --git a/projects/igniteui-angular/combo/src/combo/combo.api.ts b/projects/igniteui-angular/combo/src/combo/combo.api.ts
new file mode 100644
index 00000000000..bf48875c880
--- /dev/null
+++ b/projects/igniteui-angular/combo/src/combo/combo.api.ts
@@ -0,0 +1,57 @@
+import { IgxComboBase } from './combo.common';
+import { Injectable } from '@angular/core';
+
+/**
+ * @hidden
+ */
+@Injectable()
+export class IgxComboAPIService {
+ public disableTransitions = false;
+ protected combo: IgxComboBase;
+
+ public get valueKey() {
+ return this.combo.valueKey !== null && this.combo.valueKey !== undefined ? this.combo.valueKey : null;
+ }
+
+ public get item_focusable(): boolean {
+ return false;
+ }
+ public get isRemote(): boolean {
+ return this.combo.isRemote;
+ }
+
+ public get comboID(): string {
+ return this.combo.id;
+ }
+
+ public register(combo: IgxComboBase) {
+ this.combo = combo;
+ }
+
+ public clear(): void {
+ this.combo = null;
+ }
+
+ public add_custom_item(): void {
+ if (!this.combo) {
+ return;
+ }
+ this.combo.addItemToCollection();
+ }
+
+ public set_selected_item(itemID: any, event?: Event): void {
+ const selected = this.combo.isItemSelected(itemID);
+ if (itemID === undefined) {
+ return;
+ }
+ if (!selected) {
+ this.combo.select([itemID], false, event);
+ } else {
+ this.combo.deselect([itemID], event);
+ }
+ }
+
+ public is_item_selected(itemID: any): boolean {
+ return this.combo.isItemSelected(itemID);
+ }
+}
diff --git a/projects/igniteui-angular/combo/src/combo/combo.common.ts b/projects/igniteui-angular/combo/src/combo/combo.common.ts
new file mode 100644
index 00000000000..31b8db9df3e
--- /dev/null
+++ b/projects/igniteui-angular/combo/src/combo/combo.common.ts
@@ -0,0 +1,1417 @@
+import {
+ AfterContentChecked,
+ AfterViewChecked,
+ AfterViewInit,
+ booleanAttribute,
+ ChangeDetectorRef,
+ ContentChild,
+ ContentChildren,
+ Directive,
+ ElementRef,
+ EventEmitter,
+ forwardRef,
+ HostBinding,
+ InjectionToken,
+ Injector,
+ Input,
+ OnDestroy,
+ OnInit,
+ Output,
+ QueryList,
+ TemplateRef,
+ ViewChild,
+ DOCUMENT,
+ ViewChildren,
+ inject
+} from '@angular/core';
+import { AbstractControl, ControlValueAccessor, NgControl } from '@angular/forms';
+import { caseSensitive } from '@igniteui/material-icons-extended';
+import { noop, Subject } from 'rxjs';
+import { takeUntil } from 'rxjs/operators';
+import {
+ IgxSelectionAPIService,
+ SortingDirection,
+ CancelableBrowserEventArgs,
+ cloneArray,
+ IBaseCancelableBrowserEventArgs,
+ IBaseEventArgs,
+ rem,
+ AbsoluteScrollStrategy,
+ AutoPositionStrategy,
+ OverlaySettings,
+ ComboResourceStringsEN,
+ IComboResourceStrings,
+ getCurrentResourceStrings
+} from 'igniteui-angular/core';
+import { IForOfState, IgxForOfDirective } from 'igniteui-angular/directives';
+import { IgxIconService } from 'igniteui-angular/icon';
+import { IGX_INPUT_GROUP_TYPE, IgxInputDirective, IgxInputGroupComponent, IgxInputGroupType, IgxInputState, IgxLabelDirective, IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group';
+import { IgxComboDropDownComponent } from './combo-dropdown.component';
+import { IgxComboAPIService } from './combo.api';
+import {
+ IgxComboAddItemDirective, IgxComboClearIconDirective, IgxComboEmptyDirective,
+ IgxComboFooterDirective, IgxComboHeaderDirective, IgxComboHeaderItemDirective, IgxComboItemDirective, IgxComboToggleIconDirective
+} from './combo.directives';
+import { isEqual } from 'lodash-es';
+import { IComboItemAdditionEvent, IComboSearchInputEventArgs } from './combo.component';
+
+export const IGX_COMBO_COMPONENT = /*@__PURE__*/new InjectionToken('IgxComboComponentToken');
+
+/** @hidden @internal TODO: Evaluate */
+export interface IgxComboBase {
+ id: string;
+ data: any[] | null;
+ valueKey: string;
+ groupKey: string;
+ isRemote: boolean;
+ filteredData: any[] | null;
+ totalItemCount: number;
+ itemsMaxHeight: number;
+ itemHeight: number;
+ searchValue: string;
+ searchInput: ElementRef;
+ comboInput: ElementRef;
+ opened: EventEmitter;
+ opening: EventEmitter;
+ closing: EventEmitter;
+ closed: EventEmitter;
+ focusSearchInput(opening?: boolean): void;
+ triggerCheck(): void;
+ addItemToCollection(): void;
+ isAddButtonVisible(): boolean;
+ handleInputChange(event?: string): void;
+ isItemSelected(itemID: any): boolean;
+ select(item: any): void;
+ select(itemIDs: any[], clearSelection?: boolean, event?: Event): void;
+ deselect(...args: [] | [itemIDs: any[], event?: Event]): void;
+ setActiveDescendant(): void;
+}
+
+let NEXT_ID = 0;
+
+
+/** @hidden @internal */
+export const enum DataTypes {
+ EMPTY = 'empty',
+ PRIMITIVE = 'primitive',
+ COMPLEX = 'complex',
+ PRIMARYKEY = 'valueKey'
+}
+
+/** The filtering criteria to be applied on data search */
+export interface IComboFilteringOptions {
+ /** Defines filtering case-sensitivity */
+ caseSensitive?: boolean;
+ /** Defines optional key to filter against complex list items. Default to displayKey if provided.*/
+ filteringKey?: string;
+}
+
+@Directive()
+export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewChecked, OnInit,
+ AfterViewInit, AfterContentChecked, OnDestroy, ControlValueAccessor {
+ protected elementRef = inject(ElementRef);
+ protected cdr = inject(ChangeDetectorRef);
+ protected selectionService = inject(IgxSelectionAPIService);
+ protected comboAPI = inject(IgxComboAPIService);
+ public document = inject(DOCUMENT);
+ protected _inputGroupType = inject(IGX_INPUT_GROUP_TYPE, { optional: true });
+ protected _injector = inject(Injector, { optional: true });
+ protected _iconService = inject(IgxIconService, { optional: true });
+
+ /**
+ * Defines whether the caseSensitive icon should be shown in the search input
+ *
+ * ```typescript
+ * // get
+ * let myComboShowSearchCaseIcon = this.combo.showSearchCaseIcon;
+ * ```
+ *
+ * ```html
+ *
+ *
+ * ```
+ */
+ @Input({ transform: booleanAttribute })
+ public showSearchCaseIcon = false;
+
+ /**
+ * Enables/disables filtering in the list. The default is `false`.
+ */
+ @Input({ transform: booleanAttribute })
+ public get disableFiltering(): boolean {
+ return this._disableFiltering;
+ }
+ public set disableFiltering(value: boolean) {
+ this._disableFiltering = value;
+ }
+
+ /**
+ * Set custom overlay settings that control how the combo's list of items is displayed.
+ * Set:
+ * ```html
+ *
+ * ```
+ *
+ * ```typescript
+ * const customSettings = { positionStrategy: { settings: { target: myTarget } } };
+ * combo.overlaySettings = customSettings;
+ * ```
+ * Get any custom overlay settings used by the combo:
+ * ```typescript
+ * const comboOverlaySettings: OverlaySettings = myCombo.overlaySettings;
+ * ```
+ */
+ @Input()
+ public overlaySettings: OverlaySettings = null;
+
+ /**
+ * Gets/gets combo id.
+ *
+ * ```typescript
+ * // get
+ * let id = this.combo.id;
+ * ```
+ *
+ * ```html
+ *
+ *
+ * ```
+ */
+ @HostBinding('attr.id')
+ @Input()
+ public get id(): string {
+ return this._id;
+ }
+
+ public set id(value: string) {
+ if (!value) {
+ return;
+ }
+ const selection = this.selectionService.get(this._id);
+ this.selectionService.clear(this._id);
+ this._id = value;
+ if (selection) {
+ this.selectionService.set(this._id, selection);
+ }
+ }
+
+ /**
+ * Sets the style width of the element
+ *
+ * ```typescript
+ * // get
+ * let myComboWidth = this.combo.width;
+ * ```
+ *
+ * ```html
+ *
+ *
+ * ```
+ */
+ @HostBinding('style.width')
+ @Input()
+ public width: string;
+
+ /**
+ * Controls whether custom values can be added to the collection
+ *
+ * ```typescript
+ * // get
+ * let comboAllowsCustomValues = this.combo.allowCustomValues;
+ * ```
+ *
+ * ```html
+ *
+ *
+ * ```
+ */
+ @Input({ transform: booleanAttribute })
+ public allowCustomValues = false;
+
+ /**
+ * Configures the drop down list height
+ *
+ * ```typescript
+ * // get
+ * let myComboItemsMaxHeight = this.combo.itemsMaxHeight;
+ * ```
+ *
+ * ```html
+ *
+ *
+ * ```
+ */
+ @Input()
+ public get itemsMaxHeight(): number {
+ if (this.itemHeight && !this._itemsMaxHeight) {
+ return this.itemHeight * this.itemsInContainer;
+ }
+ return this._itemsMaxHeight;
+ }
+
+ public set itemsMaxHeight(val: number) {
+ this._itemsMaxHeight = val;
+ }
+
+ /** @hidden */
+ public get itemsMaxHeightInRem() {
+ if (this.itemsMaxHeight) {
+ return rem(this.itemsMaxHeight);
+ }
+ }
+
+ /**
+ * Configures the drop down list item height
+ *
+ * ```typescript
+ * // get
+ * let myComboItemHeight = this.combo.itemHeight;
+ * ```
+ *
+ * ```html
+ *
+ *
+ * ```
+ */
+ @Input()
+ public get itemHeight(): number {
+ return this._itemHeight;
+ }
+
+ public set itemHeight(val: number) {
+ this._itemHeight = val;
+ }
+
+ /**
+ * Configures the drop down list width
+ *
+ * ```typescript
+ * // get
+ * let myComboItemsWidth = this.combo.itemsWidth;
+ * ```
+ *
+ * ```html
+ *
+ *
+ * ```
+ */
+ @Input()
+ public itemsWidth: string;
+
+ /**
+ * Defines the placeholder value for the combo value field
+ *
+ * ```typescript
+ * // get
+ * let myComboPlaceholder = this.combo.placeholder;
+ * ```
+ *
+ * ```html
+ *
+ *
+ * ```
+ */
+ @Input()
+ public placeholder: string;
+
+ /**
+ * Combo data source.
+ *
+ * ```html
+ *
+ *
+ * ```
+ */
+ @Input()
+ public get data(): any[] | null {
+ return this._data;
+ }
+ public set data(val: any[] | null) {
+ // igxFor directive ignores undefined values
+ // if the combo uses simple data and filtering is applied
+ // an error will occur due to the mismatch of the length of the data
+ // this can occur during filtering for the igx-combo and
+ // during filtering & selection for the igx-simple-combo
+ // since the simple combo's input is both a container for the selection and a filter
+ this._data = (val) ? val.filter(x => x !== undefined) : [];
+ }
+
+ /**
+ * Determines which column in the data source is used to determine the value.
+ *
+ * ```typescript
+ * // get
+ * let myComboValueKey = this.combo.valueKey;
+ * ```
+ *
+ * ```html
+ *
+ *
+ * ```
+ */
+ @Input()
+ public valueKey: string = null;
+
+ @Input()
+ public set displayKey(val: string) {
+ this._displayKey = val;
+ }
+
+ /**
+ * Determines which column in the data source is used to determine the display value.
+ *
+ * ```typescript
+ * // get
+ * let myComboDisplayKey = this.combo.displayKey;
+ *
+ * // set
+ * this.combo.displayKey = 'val';
+ *
+ * ```
+ *
+ * ```html
+ *
+ *
+ * ```
+ */
+ public get displayKey() {
+ return this._displayKey ? this._displayKey : this.valueKey;
+ }
+
+ /**
+ * The item property by which items should be grouped inside the items list. Not usable if data is not of type Object[].
+ *
+ * ```html
+ *
+ *
+ * ```
+ */
+ @Input()
+ public set groupKey(val: string) {
+ this._groupKey = val;
+ }
+
+ /**
+ * The item property by which items should be grouped inside the items list. Not usable if data is not of type Object[].
+ *
+ * ```typescript
+ * // get
+ * let currentGroupKey = this.combo.groupKey;
+ * ```
+ */
+ public get groupKey(): string {
+ return this._groupKey;
+ }
+
+ /**
+ * Sets groups sorting order.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ * ```typescript
+ * public groupSortingDirection = SortingDirection.Asc;
+ * ```
+ */
+ @Input()
+ public get groupSortingDirection(): SortingDirection {
+ return this._groupSortingDirection;
+ }
+ public set groupSortingDirection(val: SortingDirection) {
+ this._groupSortingDirection = val;
+ }
+
+ /**
+ * Gets/Sets the custom filtering function of the combo.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ @Input()
+ public filterFunction: (collection: any[], searchValue: any, filteringOptions: IComboFilteringOptions) => any[];
+
+ /**
+ * Sets aria-labelledby attribute value.
+ * ```html
+ *
+ * ```
+ */
+ @Input()
+ public ariaLabelledBy: string;
+
+ /** @hidden @internal */
+ @HostBinding('class.igx-combo')
+ public cssClass = 'igx-combo'; // Independent of display density for the time being
+
+ /**
+ * Disables the combo. The default is `false`.
+ * ```html
+ *
+ * ```
+ */
+ @Input({ transform: booleanAttribute })
+ public disabled = false;
+
+ /**
+ * Sets the visual combo type.
+ * The allowed values are `line`, `box`, `border` and `search`. The default is `box`.
+ * ```html
+ *
+ * ```
+ */
+ @Input()
+ public get type(): IgxInputGroupType {
+ return this._type || this._inputGroupType || 'box';
+ }
+
+ public set type(val: IgxInputGroupType) {
+ this._type = val;
+ }
+
+ /**
+ * Gets/Sets the resource strings.
+ *
+ * @remarks
+ * By default it uses EN resources.
+ */
+ @Input()
+ public get resourceStrings(): IComboResourceStrings {
+ return this._resourceStrings;
+ }
+ public set resourceStrings(value: IComboResourceStrings) {
+ this._resourceStrings = Object.assign({}, this._resourceStrings, value);
+ }
+
+ /**
+ * Emitted before the dropdown is opened
+ *
+ * ```html
+ *
+ * ```
+ */
+ @Output()
+ public opening = new EventEmitter();
+
+ /**
+ * Emitted after the dropdown is opened
+ *
+ * ```html
+ *
+ * ```
+ */
+ @Output()
+ public opened = new EventEmitter();
+
+ /**
+ * Emitted before the dropdown is closed
+ *
+ * ```html
+ *
+ * ```
+ */
+ @Output()
+ public closing = new EventEmitter();
+
+ /**
+ * Emitted after the dropdown is closed
+ *
+ * ```html
+ *
+ * ```
+ */
+ @Output()
+ public closed = new EventEmitter();
+
+ /**
+ * Emitted when an item is being added to the data collection
+ *
+ * ```html
+ *
+ * ```
+ */
+ @Output()
+ public addition = new EventEmitter();
+
+ /**
+ * Emitted when the value of the search input changes (e.g. typing, pasting, clear, etc.)
+ *
+ * ```html
+ *
+ * ```
+ */
+ @Output()
+ public searchInputUpdate = new EventEmitter();
+
+ /**
+ * Emitted when new chunk of data is loaded from the virtualization
+ *
+ * ```html
+ *
+ * ```
+ */
+ @Output()
+ public dataPreLoad = new EventEmitter();
+
+ /**
+ * The custom template, if any, that should be used when rendering ITEMS in the combo list
+ *
+ * ```typescript
+ * // Set in typescript
+ * const myCustomTemplate: TemplateRef = myComponent.customTemplate;
+ * myComponent.combo.itemTemplate = myCustomTemplate;
+ * ```
+ * ```html
+ *
+ *
+ * ...
+ *
+ *
+ * {{ item[key] }}
+ * {{ item.cost }}
+ *
+ *
+ *
+ * ```
+ */
+ @ContentChild(IgxComboItemDirective, { read: TemplateRef })
+ public itemTemplate: TemplateRef = null;
+
+ /**
+ * The custom template, if any, that should be used when rendering the HEADER for the combo items list
+ *
+ * ```typescript
+ * // Set in typescript
+ * const myCustomTemplate: TemplateRef = myComponent.customTemplate;
+ * myComponent.combo.headerTemplate = myCustomTemplate;
+ * ```
+ * ```html
+ *
+ *
+ * ...
+ *
+ *