From 5675104d7607c77ac263223db8ea8fd6f0ed18df Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Thu, 30 Oct 2025 15:01:15 +0100 Subject: [PATCH 1/3] feat(chat-messages): add chat input --- .../element-ng/chat-messages/index.api.md | 98 ++++ api-goldens/element-ng/translate/index.api.md | 12 + projects/element-ng/chat-messages/index.ts | 2 + .../si-chat-input-disclaimer.directive.ts | 23 + .../si-chat-input.component.html | 108 ++++ .../si-chat-input.component.scss | 45 ++ .../si-chat-input.component.spec.ts | 493 ++++++++++++++++++ .../chat-messages/si-chat-input.component.ts | 401 ++++++++++++++ .../si-translatable-keys.interface.ts | 6 + 9 files changed, 1188 insertions(+) create mode 100644 projects/element-ng/chat-messages/si-chat-input-disclaimer.directive.ts create mode 100644 projects/element-ng/chat-messages/si-chat-input.component.html create mode 100644 projects/element-ng/chat-messages/si-chat-input.component.scss create mode 100644 projects/element-ng/chat-messages/si-chat-input.component.spec.ts create mode 100644 projects/element-ng/chat-messages/si-chat-input.component.ts diff --git a/api-goldens/element-ng/chat-messages/index.api.md b/api-goldens/element-ng/chat-messages/index.api.md index 5f934acfd..71f834212 100644 --- a/api-goldens/element-ng/chat-messages/index.api.md +++ b/api-goldens/element-ng/chat-messages/index.api.md @@ -4,14 +4,18 @@ ```ts +import { AfterViewInit } from '@angular/core'; import * as _angular_core from '@angular/core'; import { ElementRef } from '@angular/core'; +import { FileUploadError } from '@siemens/element-ng/file-uploader'; import * as i1 from '@siemens/element-ng/resize-observer'; import { MenuItem } from '@siemens/element-ng/menu'; import * as _siemens_element_translate_ng_translate_types from '@siemens/element-translate-ng/translate-types'; import { SiModalService } from '@siemens/element-ng/modal'; import { TemplateRef } from '@angular/core'; import { TranslatableString } from '@siemens/element-translate-ng/translate-types'; +import { TranslatableString as TranslatableString_2 } from '@siemens/element-translate-ng/translate'; +import { UploadFile } from '@siemens/element-ng/file-uploader'; // @public export interface Attachment { @@ -19,6 +23,13 @@ export interface Attachment { previewTemplate?: TemplateRef | (() => TemplateRef); } +// @public (undocumented) +export interface ChatInputAttachment extends Attachment { + file: File; + size: number; + type: string; +} + // @public export interface MessageAction { action: (actionParam: any, source: this) => void; @@ -66,6 +77,93 @@ export class SiAttachmentListComponent { static ɵfac: _angular_core.ɵɵFactoryDeclaration; } +// @public (undocumented) +export class SiChatInputComponent implements AfterViewInit { + readonly accept: _angular_core.InputSignal; + readonly actionParam: _angular_core.InputSignal; + readonly actions: _angular_core.InputSignal; + // (undocumented) + protected adjustTextareaHeight(event: Event): void; + readonly allowAttachments: _angular_core.InputSignal; + readonly attachFileLabel: _angular_core.InputSignal; + // (undocumented) + protected get attachmentList(): Attachment[]; + readonly attachments: _angular_core.ModelSignal; + readonly autoFocus: _angular_core.InputSignalWithTransform; + // (undocumented) + protected readonly buttonDisabled: _angular_core.Signal; + // (undocumented) + protected readonly buttonIcon: _angular_core.Signal; + // (undocumented) + protected readonly buttonLabel: _angular_core.Signal; + // (undocumented) + protected readonly canSend: _angular_core.Signal; + readonly disabled: _angular_core.InputSignalWithTransform; + readonly disclaimer: _angular_core.InputSignal; + readonly fileError: _angular_core.OutputEmitterRef; + focus(): void; + // (undocumented) + protected readonly hasActions: _angular_core.Signal; + // (undocumented) + protected readonly hasAttachments: _angular_core.Signal; + // (undocumented) + protected readonly hasContent: _angular_core.Signal; + // (undocumented) + protected readonly hasSecondaryActions: _angular_core.Signal; + // (undocumented) + protected readonly id: string; + readonly interrupt: _angular_core.OutputEmitterRef; + readonly interruptButtonLabel: _angular_core.InputSignal; + readonly interruptible: _angular_core.InputSignalWithTransform; + readonly label: _angular_core.InputSignal; + readonly maxFileSize: _angular_core.InputSignal; + readonly maxLength: _angular_core.InputSignal; + // (undocumented) + ngAfterViewInit(): void; + // (undocumented) + protected onButtonClick(): void; + // (undocumented) + protected onContainerClick(event: Event): void; + // (undocumented) + protected onFileError(error: FileUploadError): void; + // (undocumented) + protected onFilesAdded(uploadFiles: UploadFile[]): void; + // (undocumented) + protected onInputChange(value: string): void; + // (undocumented) + protected onKeyDown(event: KeyboardEvent): void; + // (undocumented) + protected onSend(): void; + readonly placeholder: _angular_core.InputSignal; + // (undocumented) + protected removeAttachment(attachment: Attachment): void; + readonly removeAttachmentLabel: _angular_core.InputSignal; + readonly secondaryActions: _angular_core.InputSignal; + readonly secondaryActionsLabel: _angular_core.InputSignal; + readonly send: _angular_core.OutputEmitterRef<{ + content: string; + attachments: ChatInputAttachment[]; + }>; + readonly sendButtonIcon: _angular_core.InputSignal; + readonly sendButtonLabel: _angular_core.InputSignal; + readonly sending: _angular_core.InputSignalWithTransform; + // (undocumented) + protected readonly showInterruptButton: _angular_core.Signal; + readonly value: _angular_core.ModelSignal; + // (undocumented) + static ɵcmp: _angular_core.ɵɵComponentDeclaration; + // (undocumented) + static ɵfac: _angular_core.ɵɵFactoryDeclaration; +} + +// @public +export class SiChatInputDisclaimerDirective { + // (undocumented) + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: _angular_core.ɵɵFactoryDeclaration; +} + // @public export class SiChatMessageActionDirective { // (undocumented) diff --git a/api-goldens/element-ng/translate/index.api.md b/api-goldens/element-ng/translate/index.api.md index 43eca7a38..ecc27fc26 100644 --- a/api-goldens/element-ng/translate/index.api.md +++ b/api-goldens/element-ng/translate/index.api.md @@ -38,6 +38,18 @@ export interface SiTranslatableKeys { // (undocumented) 'SI_CHANGE_PASSWORD.PASSWORD_POLICY'?: string; // (undocumented) + 'SI_CHAT_INPUT.ATTACH_FILE'?: string; + // (undocumented) + 'SI_CHAT_INPUT.INTERRUPT'?: string; + // (undocumented) + 'SI_CHAT_INPUT.LABEL'?: string; + // (undocumented) + 'SI_CHAT_INPUT.PLACEHOLDER'?: string; + // (undocumented) + 'SI_CHAT_INPUT.SECONDARY_ACTIONS'?: string; + // (undocumented) + 'SI_CHAT_INPUT.SEND'?: string; + // (undocumented) 'SI_COLUMN_SELECTION_DIALOG.CANCEL'?: string; // (undocumented) 'SI_COLUMN_SELECTION_DIALOG.HIDDEN'?: string; diff --git a/projects/element-ng/chat-messages/index.ts b/projects/element-ng/chat-messages/index.ts index 8bf671535..ed9aaad10 100644 --- a/projects/element-ng/chat-messages/index.ts +++ b/projects/element-ng/chat-messages/index.ts @@ -4,6 +4,8 @@ */ export * from './si-ai-message.component'; export * from './si-attachment-list.component'; +export * from './si-chat-input.component'; +export * from './si-chat-input-disclaimer.directive'; export * from './si-chat-message-action.directive'; export * from './si-chat-message.component'; export * from './si-user-message.component'; diff --git a/projects/element-ng/chat-messages/si-chat-input-disclaimer.directive.ts b/projects/element-ng/chat-messages/si-chat-input-disclaimer.directive.ts new file mode 100644 index 000000000..63bda7a84 --- /dev/null +++ b/projects/element-ng/chat-messages/si-chat-input-disclaimer.directive.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +import { Directive } from '@angular/core'; + +/** + * Directive to mark content as chat input disclaimer. + * Apply this directive to content that should be slotted into the disclaimer area. + * + * @example + * ```html + * + *
+ * Custom disclaimer content + *
+ *
+ * ``` + */ +@Directive({ + selector: '[siChatInputDisclaimer]' +}) +export class SiChatInputDisclaimerDirective {} diff --git a/projects/element-ng/chat-messages/si-chat-input.component.html b/projects/element-ng/chat-messages/si-chat-input.component.html new file mode 100644 index 000000000..3aaa76400 --- /dev/null +++ b/projects/element-ng/chat-messages/si-chat-input.component.html @@ -0,0 +1,108 @@ +
+ @if (hasAttachments()) { +
+ +
+ } + +
+ + +
+ +
+
+ @if (allowAttachments()) { + + + + } + + @if (hasActions() || hasSecondaryActions()) { +
+ @for (action of actions(); track $index) { + + } + + @if (secondaryActions().length > 0) { + + + + + + } +
+ } +
+ +
+
+ + +
+
+ +
+ @if (disclaimer()) { + {{ disclaimer() | translate }} + } + +
diff --git a/projects/element-ng/chat-messages/si-chat-input.component.scss b/projects/element-ng/chat-messages/si-chat-input.component.scss new file mode 100644 index 000000000..119bb4647 --- /dev/null +++ b/projects/element-ng/chat-messages/si-chat-input.component.scss @@ -0,0 +1,45 @@ +@use 'sass:map'; +@use '@siemens/element-theme/src/styles/variables'; + +:host { + max-inline-size: 720px; +} + +.input-wrapper { + border-color: variables.$element-ui-4; + background-color: variables.$element-base-input-experimental; +} + +.chat-textarea { + // Prevent layout shift on first input + min-block-size: calc(1.5em * variables.$si-line-height-body); + font-family: inherit; + outline: none; + resize: none; + // stylelint-disable-next-line declaration-no-important + background-color: transparent !important; + + &::placeholder { + color: variables.$element-text-secondary; + } + + &:disabled { + // stylelint-disable-next-line declaration-no-important + background-color: transparent !important; + color: variables.$element-text-disabled; + cursor: not-allowed; + + &::placeholder { + color: variables.$element-text-disabled; + } + } +} + +.input-wrapper:has(.chat-textarea:focus-visible) { + @include variables.make-outline-focus(); + border-color: variables.$element-ui-1; +} + +.disclaimer-wrapper:empty { + display: none; +} diff --git a/projects/element-ng/chat-messages/si-chat-input.component.spec.ts b/projects/element-ng/chat-messages/si-chat-input.component.spec.ts new file mode 100644 index 000000000..fd275404a --- /dev/null +++ b/projects/element-ng/chat-messages/si-chat-input.component.spec.ts @@ -0,0 +1,493 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +import { DebugElement } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; +import { FileUploadError, UploadFile } from '@siemens/element-ng/file-uploader'; + +import { MessageAction } from './message-action.model'; +import { + ChatInputAttachment, + SiChatInputComponent as TestComponent +} from './si-chat-input.component'; + +describe('SiChatInputComponent', () => { + let fixture: ComponentFixture; + let debugElement: DebugElement; + let component: TestComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestComponent], + providers: [provideNoopAnimations()] + }).compileComponents(); + + fixture = TestBed.createComponent(TestComponent); + debugElement = fixture.debugElement; + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have default empty value', () => { + fixture.detectChanges(); + expect(component.value()).toBe(''); + }); + + it('should have default disabled state of false', () => { + fixture.detectChanges(); + expect(component.disabled()).toBe(false); + }); + + it('should have default sending state of false', () => { + fixture.detectChanges(); + expect(component.sending()).toBe(false); + }); + + it('should have default empty actions array', () => { + fixture.detectChanges(); + expect(component.actions()).toEqual([]); + }); + + it('should have default empty secondary actions array', () => { + fixture.detectChanges(); + expect(component.secondaryActions()).toEqual([]); + }); + + it('should have default empty attachments array', () => { + fixture.detectChanges(); + expect(component.attachments()).toEqual([]); + }); + + it('should have default allowAttachments of false', () => { + fixture.detectChanges(); + expect(component.allowAttachments()).toBe(false); + }); + + it('should have default interruptible state of false', () => { + fixture.detectChanges(); + expect(component.interruptible()).toBe(false); + }); + + it('should have default maxFileSize of 10MB', () => { + fixture.detectChanges(); + expect(component.maxFileSize()).toBe(10485760); + }); + + it('should render textarea input', () => { + fixture.detectChanges(); + + const textarea = debugElement.query(By.css('textarea')); + expect(textarea).toBeTruthy(); + }); + + it('should render send button', () => { + fixture.detectChanges(); + + const sendButton = debugElement.query(By.css('button')); + expect(sendButton).toBeTruthy(); + }); + + it('should disable send button when no content and no attachments', () => { + fixture.componentRef.setInput('value', ''); + fixture.componentRef.setInput('attachments', []); + fixture.detectChanges(); + + expect((component as any).canSend()).toBe(false); + }); + + it('should enable send button when there is content', () => { + component.value.set('Hello'); + fixture.detectChanges(); + + expect((component as any).canSend()).toBe(true); + }); + + it('should enable send button when there are attachments', () => { + const attachments: ChatInputAttachment[] = [ + { + name: 'file.txt', + file: new File(['content'], 'file.txt'), + size: 100, + type: 'text/plain' + } + ]; + component.attachments.set(attachments); + fixture.detectChanges(); + + expect((component as any).canSend()).toBe(true); + }); + + it('should disable send button when disabled is true', () => { + component.value.set('Hello'); + fixture.componentRef.setInput('disabled', true); + fixture.detectChanges(); + + expect((component as any).canSend()).toBe(false); + }); + + it('should disable send button when sending is true', () => { + component.value.set('Hello'); + fixture.componentRef.setInput('sending', true); + fixture.detectChanges(); + + expect((component as any).canSend()).toBe(false); + }); + + it('should emit send event when send button is clicked', () => { + let emittedData: any; + component.send.subscribe((data: any) => { + emittedData = data; + }); + + component.value.set('Test message'); + fixture.detectChanges(); + + const sendButton = debugElement.query(By.css('button')); + sendButton.nativeElement.click(); + + expect(emittedData).toEqual({ + content: 'Test message', + attachments: [] + }); + }); + + it('should clear input after sending', () => { + component.value.set('Test message'); + fixture.detectChanges(); + + (component as any).onSend(); + + expect(component.value()).toBe(''); + }); + + it('should clear attachments after sending', () => { + const attachments: ChatInputAttachment[] = [ + { + name: 'file.txt', + file: new File(['content'], 'file.txt'), + size: 100, + type: 'text/plain' + } + ]; + component.attachments.set(attachments); + fixture.detectChanges(); + + (component as any).onSend(); + + expect(component.attachments()).toEqual([]); + }); + + it('should send on Enter key press', () => { + let emittedData: any; + component.send.subscribe((data: any) => { + emittedData = data; + }); + + component.value.set('Test message'); + fixture.detectChanges(); + + const textarea = debugElement.query(By.css('textarea')); + const event = new KeyboardEvent('keydown', { key: 'Enter', shiftKey: false }); + textarea.nativeElement.dispatchEvent(event); + + expect(emittedData).toBeDefined(); + }); + + it('should not send on Shift+Enter key press', () => { + let emittedCount = 0; + component.send.subscribe(() => { + emittedCount++; + }); + + component.value.set('Test message'); + fixture.detectChanges(); + + const textarea = debugElement.query(By.css('textarea')); + const event = new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true }); + spyOn(event, 'preventDefault'); + textarea.nativeElement.dispatchEvent(event); + + expect(emittedCount).toBe(0); + }); + + it('should not send on Enter when interruptible is true', () => { + let emittedCount = 0; + component.send.subscribe(() => { + emittedCount++; + }); + + component.value.set('Test message'); + fixture.componentRef.setInput('interruptible', true); + fixture.detectChanges(); + + const textarea = debugElement.query(By.css('textarea')); + const event = new KeyboardEvent('keydown', { key: 'Enter', shiftKey: false }); + spyOn(event, 'preventDefault'); + textarea.nativeElement.dispatchEvent(event); + + expect(emittedCount).toBe(0); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('should not render attachment list when no attachments', () => { + fixture.componentRef.setInput('attachments', []); + fixture.detectChanges(); + + const attachmentList = debugElement.query(By.css('si-attachment-list')); + expect(attachmentList).toBeFalsy(); + }); + + it('should render attachment list when attachments exist', () => { + const attachments: ChatInputAttachment[] = [ + { + name: 'file.txt', + file: new File(['content'], 'file.txt'), + size: 100, + type: 'text/plain' + } + ]; + component.attachments.set(attachments); + fixture.detectChanges(); + + const attachmentList = debugElement.query(By.css('si-attachment-list')); + expect(attachmentList).toBeTruthy(); + }); + + it('should remove attachment when remove is triggered', () => { + const attachments: ChatInputAttachment[] = [ + { + name: 'file1.txt', + file: new File(['content1'], 'file1.txt'), + size: 100, + type: 'text/plain' + }, + { + name: 'file2.txt', + file: new File(['content2'], 'file2.txt'), + size: 200, + type: 'text/plain' + } + ]; + component.attachments.set(attachments); + fixture.detectChanges(); + + (component as any).removeAttachment(attachments[0]); + + expect(component.attachments().length).toBe(1); + expect(component.attachments()[0].name).toBe('file2.txt'); + }); + + it('should add files on file upload', () => { + const mockFile = new File(['content'], 'test.txt', { type: 'text/plain' }); + const uploadFiles: UploadFile[] = [ + { + fileName: 'test.txt', + file: mockFile, + status: 'added' + } as UploadFile + ]; + + (component as any).onFilesAdded(uploadFiles); + + expect(component.attachments().length).toBe(1); + expect(component.attachments()[0].name).toBe('test.txt'); + expect(component.attachments()[0].file).toBe(mockFile); + }); + + it('should filter out non-added files', () => { + const uploadFiles: UploadFile[] = [ + { + fileName: 'test1.txt', + file: new File(['content1'], 'test1.txt'), + status: 'added' + } as UploadFile, + { + fileName: 'test2.txt', + file: new File(['content2'], 'test2.txt'), + status: 'error' + } as UploadFile + ]; + + (component as any).onFilesAdded(uploadFiles); + + expect(component.attachments().length).toBe(1); + expect(component.attachments()[0].name).toBe('test1.txt'); + }); + + it('should emit file error event', () => { + let emittedError: FileUploadError | undefined; + component.fileError.subscribe((error: FileUploadError) => { + emittedError = error; + }); + + const mockError = { + fileName: 'large.txt' + } as FileUploadError; + + (component as any).onFileError(mockError); + + expect(emittedError).toEqual(mockError); + }); + + it('should use custom placeholder', () => { + const customPlaceholder = 'Type your message here...'; + fixture.componentRef.setInput('placeholder', customPlaceholder); + fixture.detectChanges(); + + const textarea = debugElement.query(By.css('textarea')); + expect(textarea.nativeElement.placeholder).toBe(customPlaceholder); + }); + + it('should use custom send button label', () => { + const customLabel = 'Submit'; + fixture.componentRef.setInput('sendButtonLabel', customLabel); + fixture.detectChanges(); + + const sendButton = debugElement.query(By.css('button')); + expect(sendButton.nativeElement.getAttribute('aria-label')).toContain(customLabel); + }); + + it('should use custom send button icon', () => { + const customIcon = 'element-check'; + fixture.componentRef.setInput('sendButtonIcon', customIcon); + fixture.detectChanges(); + + const icon = debugElement.query(By.css('button si-icon')); + expect(icon.componentInstance.icon()).toBe(customIcon); + }); + + it('should show stop icon when interruptible is true', () => { + fixture.componentRef.setInput('interruptible', true); + fixture.detectChanges(); + + const icon = debugElement.query(By.css('button si-icon')); + expect(icon.componentInstance.icon()).toBe('element-stop-filled'); + }); + + it('should use interrupt button label when interruptible is true', () => { + fixture.componentRef.setInput('interruptible', true); + fixture.detectChanges(); + + const button = debugElement.query(By.css('button')); + expect(button.nativeElement.getAttribute('aria-label')).toContain('Interrupt'); + }); + + it('should emit interrupt event when interrupt button is clicked', () => { + let interruptEmitted = false; + component.interrupt.subscribe(() => { + interruptEmitted = true; + }); + + fixture.componentRef.setInput('interruptible', true); + fixture.detectChanges(); + + const button = debugElement.query(By.css('button')); + button.nativeElement.click(); + + expect(interruptEmitted).toBe(true); + }); + + it('should disable interrupt button when sending is true', () => { + fixture.componentRef.setInput('interruptible', true); + fixture.componentRef.setInput('sending', true); + fixture.detectChanges(); + + const button = debugElement.query(By.css('button')); + expect(button.nativeElement.disabled).toBe(true); + }); + + it('should disable interrupt button when disabled is true', () => { + fixture.componentRef.setInput('interruptible', true); + fixture.componentRef.setInput('disabled', true); + fixture.detectChanges(); + + const button = debugElement.query(By.css('button')); + expect(button.nativeElement.disabled).toBe(true); + }); + + it('should not clear input and attachments when interrupt is triggered', () => { + const attachments: ChatInputAttachment[] = [ + { + name: 'file.txt', + file: new File(['content'], 'file.txt'), + size: 100, + type: 'text/plain' + } + ]; + + component.value.set('Test message'); + component.attachments.set(attachments); + fixture.componentRef.setInput('interruptible', true); + fixture.detectChanges(); + + (component as any).onButtonClick(); + + expect(component.value()).toBe('Test message'); + expect(component.attachments()).toEqual(attachments); + }); + + it('should respect maxLength', () => { + fixture.componentRef.setInput('maxLength', 10); + fixture.detectChanges(); + + const textarea = debugElement.query(By.css('textarea')); + expect(textarea.nativeElement.maxLength).toBe(10); + }); + + it('should show disclaimer when provided', () => { + const disclaimer = 'This is a disclaimer'; + fixture.componentRef.setInput('disclaimer', disclaimer); + fixture.detectChanges(); + + const disclaimerElement = debugElement.query(By.css('.si-caption')); + expect(disclaimerElement).toBeTruthy(); + expect(disclaimerElement.nativeElement.textContent).toContain(disclaimer); + }); + + it('should render action buttons when actions are provided', () => { + const actions: MessageAction[] = [ + { + label: 'Attach', + icon: 'element-attachment', + action: () => {} + } + ]; + + fixture.componentRef.setInput('actions', actions); + fixture.detectChanges(); + + const actionButtons = fixture.nativeElement.querySelectorAll('[siChatMessageAction] button'); + expect(actionButtons.length).toBe(1); + expect(actionButtons[0].getAttribute('aria-label')).toBe('Attach'); + }); + + it('should have focus method', () => { + fixture.detectChanges(); + expect(typeof component.focus).toBe('function'); + }); + + it('should use send mode when interruptible is false', () => { + component.value.set('Test message'); + fixture.componentRef.setInput('interruptible', false); + fixture.detectChanges(); + + expect((component as any).showInterruptButton()).toBe(false); + expect((component as any).buttonIcon()).toBe('element-send-filled'); + expect((component as any).buttonLabel()).toContain('Send'); + }); + + it('should use interrupt mode when interruptible is true', () => { + fixture.componentRef.setInput('interruptible', true); + fixture.detectChanges(); + + expect((component as any).showInterruptButton()).toBe(true); + expect((component as any).buttonIcon()).toBe('element-stop-filled'); + expect((component as any).buttonLabel()).toContain('Interrupt'); + }); +}); diff --git a/projects/element-ng/chat-messages/si-chat-input.component.ts b/projects/element-ng/chat-messages/si-chat-input.component.ts new file mode 100644 index 000000000..e17cdfc8f --- /dev/null +++ b/projects/element-ng/chat-messages/si-chat-input.component.ts @@ -0,0 +1,401 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +import { CdkMenuTrigger } from '@angular/cdk/menu'; +import { + AfterViewInit, + booleanAttribute, + Component, + computed, + ElementRef, + input, + model, + output, + viewChild +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { + SiFileUploadDirective, + UploadFile, + FileUploadError +} from '@siemens/element-ng/file-uploader'; +import { SiIconComponent } from '@siemens/element-ng/icon'; +import { MenuItem, SiMenuFactoryComponent } from '@siemens/element-ng/menu'; +import { SiTranslatePipe, TranslatableString, t } from '@siemens/element-translate-ng/translate'; + +import { MessageAction } from './message-action.model'; +import { SiAttachmentListComponent, Attachment } from './si-attachment-list.component'; + +export interface ChatInputAttachment extends Attachment { + /** File object */ + file: File; + /** File size in bytes */ + size: number; + /** MIME type */ + type: string; +} + +@Component({ + selector: 'si-chat-input', + imports: [ + CdkMenuTrigger, + FormsModule, + SiIconComponent, + SiTranslatePipe, + SiAttachmentListComponent, + SiMenuFactoryComponent, + SiFileUploadDirective + ], + templateUrl: './si-chat-input.component.html', + styleUrl: './si-chat-input.component.scss' +}) +export class SiChatInputComponent implements AfterViewInit { + private static idCounter = 0; + private readonly textInput = viewChild>('textInput'); + private readonly projectedContent = viewChild('projected'); + + /** + * Current input value + * @defaultValue '' + */ + readonly value = model(''); + + /** + * Placeholder text for the input + * + * @defaultValue + * ``` + * t(() => $localize`:@@SI_CHAT_INPUT.PLACEHOLDER:Enter a message…`) + * ``` + */ + readonly placeholder = input( + t(() => $localize`:@@SI_CHAT_INPUT.PLACEHOLDER:Enter a message…`) + ); + + /** + * Whether the input is disabled + * @defaultValue false + */ + readonly disabled = input(false, { transform: booleanAttribute }); + + /** + * Whether a message is currently being sent, also prevent the sending of new ones while still allowing the user to type + * @defaultValue false + */ + readonly sending = input(false, { transform: booleanAttribute }); + + /** + * Whether the input supports interrupting ongoing operations. When active, + * the send button transforms into an interrupt button (with element-stop-filled icon). + * If sending is true, the interrupt button will be disabled. + * @defaultValue false + */ + readonly interruptible = input(false, { transform: booleanAttribute }); + + /** + * Maximum number of characters allowed + */ + readonly maxLength = input(); + + /** + * A disclaimer to display. + * + * If not provided, the component will look for projected content with the `siChatInputDisclaimer` directive. + * If both are empty, no disclaimer section will be shown (handled via CSS :empty). + */ + readonly disclaimer = input(); + + /** + * Primary actions available in the input (attach files, etc.) + * All actions displayed inline + * @defaultValue [] + */ + readonly actions = input([]); + + /** + * Secondary actions available in dropdown menu + * @defaultValue [] + */ + readonly secondaryActions = input([]); + + /** + * Whether file attachments are supported + * @defaultValue false + */ + readonly allowAttachments = input(false); + + /** + * Accepted file types for attachments (as accept string) + * @defaultValue undefined + */ + readonly accept = input(); + + /** + * Maximum file size in bytes + * @defaultValue 10485760 (10MB) + */ + readonly maxFileSize = input(10485760); + + /** + * Current attachments + * @defaultValue [] + */ + readonly attachments = model([]); + + /** + * The label for the input, used for accessibility + * @defaultValue + * ``` + * t(() => $localize`:@@SI_CHAT_INPUT.LABEL:Chat message input`) + * ``` + */ + readonly label = input(t(() => $localize`:@@SI_CHAT_INPUT.LABEL:Chat message input`)); + + /** Parameter to pass to action handlers */ + readonly actionParam = input(); + + /** + * Send button label + * + * @defaultValue + * ``` + * t(() => $localize`:@@SI_CHAT_INPUT.SEND:Send`) + * ``` + */ + readonly sendButtonLabel = input( + t(() => $localize`:@@SI_CHAT_INPUT.SEND:Send`) + ); + + /** + * Send button icon + * + * @defaultValue 'element-send-filled' + */ + readonly sendButtonIcon = input('element-send-filled'); + + /** + * Interrupt button label + * + * @defaultValue + * ``` + * t(() => $localize`:@@SI_CHAT_INPUT.INTERRUPT:Interrupt`) + * ``` + */ + readonly interruptButtonLabel = input( + t(() => $localize`:@@SI_CHAT_INPUT.INTERRUPT:Interrupt`) + ); + + /** + * Auto-focus the input on component initialization + * @defaultValue false + */ + readonly autoFocus = input(false, { transform: booleanAttribute }); + + /** + * Attach file button aria label + * + * @defaultValue + * ``` + * t(() => $localize`:@@SI_CHAT_INPUT.ATTACH_FILE:Attach file`) + * ``` + */ + readonly attachFileLabel = input( + t(() => $localize`:@@SI_CHAT_INPUT.ATTACH_FILE:Attach file`) + ); + + /** + * Remove attachment aria label prefix + * + * @defaultValue + * ``` + * t(() => $localize`:@@SI_ATTACHMENT_LIST.REMOVE_ATTACHMENT:Remove attachment`) + * ``` + */ + readonly removeAttachmentLabel = input( + t(() => $localize`:@@SI_ATTACHMENT_LIST.REMOVE_ATTACHMENT:Remove attachment`) + ); + + /** + * More actions button aria label + * + * @defaultValue + * ``` + * t(() => $localize`:@@SI_CHAT_INPUT.SECONDARY_ACTIONS:More actions`) + * ``` + */ + readonly secondaryActionsLabel = input( + t(() => $localize`:@@SI_CHAT_INPUT.SECONDARY_ACTIONS:More actions`) + ); + + /** + * Emitted when the user wants to send a message + */ + readonly send = output<{ + content: string; + attachments: ChatInputAttachment[]; + }>(); + + /** + * Emitted when the user wants to interrupt the current operation + */ + readonly interrupt = output(); + + /** + * Emitted when file upload errors occur + */ + readonly fileError = output(); + + protected readonly id = `__si-chat-input-${SiChatInputComponent.idCounter++}`; + protected readonly hasContent = computed(() => this.value().trim().length > 0); + protected readonly hasAttachments = computed(() => this.attachments().length > 0); + protected readonly hasActions = computed(() => this.actions().length > 0); + protected readonly hasSecondaryActions = computed(() => this.secondaryActions().length > 0); + protected readonly canSend = computed( + () => (this.hasContent() || this.hasAttachments()) && !this.disabled() && !this.sending() + ); + + protected readonly showInterruptButton = computed(() => this.interruptible()); + protected readonly buttonDisabled = computed(() => { + if (this.showInterruptButton()) { + return this.disabled() || this.sending(); + } + return !this.canSend(); + }); + protected readonly buttonIcon = computed(() => + this.showInterruptButton() ? 'element-stop-filled' : this.sendButtonIcon() + ); + protected readonly buttonLabel = computed(() => + this.showInterruptButton() ? this.interruptButtonLabel() : this.sendButtonLabel() + ); + + protected get attachmentList(): Attachment[] { + return this.attachments() as Attachment[]; + } + + protected onInputChange(value: string): void { + this.value.set(value); + } + + protected onSend(): void { + if (this.canSend()) { + this.send.emit({ + content: this.value(), + attachments: this.attachments() + }); + + this.value.set(''); + this.attachments.set([]); + } + } + + protected onButtonClick(): void { + if (this.showInterruptButton()) { + this.interrupt.emit(); + } else { + this.onSend(); + } + } + + protected onKeyDown(event: KeyboardEvent): void { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + if (!this.showInterruptButton()) { + this.onSend(); + } + } + } + + protected onFilesAdded(uploadFiles: UploadFile[]): void { + const validFiles = uploadFiles.filter(uploadFile => uploadFile.status === 'added'); + + validFiles.forEach(uploadFile => { + const attachment: ChatInputAttachment = { + name: uploadFile.fileName, + size: uploadFile.file.size, + type: uploadFile.file.type, + file: uploadFile.file + }; + + this.attachments.update(current => [...current, attachment]); + }); + } + + protected onFileError(error: FileUploadError): void { + this.fileError.emit(error); + } + + protected removeAttachment(attachment: Attachment): void { + this.attachments.update(current => { + return current.filter(a => a !== attachment); + }); + } + + protected onContainerClick(event: Event): void { + const target = event.target as HTMLElement; + + // Don't focus if clicking on interactive elements + if ( + target.tagName === 'BUTTON' || + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.closest('button') || + target.closest('[siChatMessageAction]') || + (target.closest('si-attachment-list') && target.closest('.attachment-item')) || + this.projectedContent()?.nativeElement?.contains(target) + ) { + return; + } + + this.focus(); + } + + ngAfterViewInit(): void { + const textarea = this.textInput(); + if (textarea?.nativeElement) { + this.setTextareaHeight(textarea.nativeElement); + + if (this.autoFocus()) { + // Use setTimeout to ensure the element is fully rendered + setTimeout(() => { + textarea.nativeElement.focus(); + }, 0); + } + } + } + + protected adjustTextareaHeight(event: Event): void { + const textarea = event.target as HTMLTextAreaElement; + this.setTextareaHeight(textarea); + } + + /** + * Focus the textarea input + */ + focus(): void { + const textarea = this.textInput(); + if (textarea?.nativeElement) { + textarea.nativeElement.focus(); + } + } + + private setTextareaHeight(textarea: HTMLTextAreaElement): void { + textarea.style.blockSize = 'auto'; + + const computedStyle = window.getComputedStyle(textarea); + const lineHeight = + parseInt(computedStyle.lineHeight, 10) || parseInt(computedStyle.fontSize, 10) * 1.2; + const paddingTop = parseInt(computedStyle.paddingBlockStart, 10) || 0; + const paddingBottom = parseInt(computedStyle.paddingBlockEnd, 10) || 0; + const minHeight = lineHeight + paddingTop + paddingBottom; + + const viewportHeight = window.innerHeight; + const maxViewportHeight = viewportHeight * 0.3; + const maxLinesHeight = lineHeight * 8; + const maxHeight = Math.min(maxViewportHeight, maxLinesHeight) + paddingTop + paddingBottom; + + const scrollHeight = textarea.scrollHeight; + const finalHeight = Math.max(Math.min(scrollHeight, maxHeight), minHeight); + textarea.style.height = finalHeight + 'px'; + } +} diff --git a/projects/element-ng/translate/si-translatable-keys.interface.ts b/projects/element-ng/translate/si-translatable-keys.interface.ts index 458d4f5ce..1d5a62b75 100644 --- a/projects/element-ng/translate/si-translatable-keys.interface.ts +++ b/projects/element-ng/translate/si-translatable-keys.interface.ts @@ -17,6 +17,12 @@ export interface SiTranslatableKeys { 'SI_CHANGE_PASSWORD.CONFIRM_PASSWORD'?: string; 'SI_CHANGE_PASSWORD.NEW_PASSWORD'?: string; 'SI_CHANGE_PASSWORD.PASSWORD_POLICY'?: string; + 'SI_CHAT_INPUT.ATTACH_FILE'?: string; + 'SI_CHAT_INPUT.INTERRUPT'?: string; + 'SI_CHAT_INPUT.LABEL'?: string; + 'SI_CHAT_INPUT.PLACEHOLDER'?: string; + 'SI_CHAT_INPUT.SECONDARY_ACTIONS'?: string; + 'SI_CHAT_INPUT.SEND'?: string; 'SI_COLUMN_SELECTION_DIALOG.CANCEL'?: string; 'SI_COLUMN_SELECTION_DIALOG.HIDDEN'?: string; 'SI_COLUMN_SELECTION_DIALOG.ITEM_MOVED'?: string; From 2297c343599d4b640f4fe0311daa76d6a8881921 Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Thu, 30 Oct 2025 15:09:25 +0100 Subject: [PATCH 2/3] docs(chat-messages): add example and document chat-input --- docs/components/chat-messages/chat-input.md | 6 +- .../si-chat-messages/si-chat-input.html | 34 ++++++ .../si-chat-messages/si-chat-input.ts | 104 ++++++++++++++++++ 3 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 src/app/examples/si-chat-messages/si-chat-input.html create mode 100644 src/app/examples/si-chat-messages/si-chat-input.ts diff --git a/docs/components/chat-messages/chat-input.md b/docs/components/chat-messages/chat-input.md index 4edff95d7..dde2b0af5 100644 --- a/docs/components/chat-messages/chat-input.md +++ b/docs/components/chat-messages/chat-input.md @@ -62,4 +62,8 @@ If multiple attachments are added, they wrap and stack within the input field to ## Code --- -Angular component is coming soon. + + + + + diff --git a/src/app/examples/si-chat-messages/si-chat-input.html b/src/app/examples/si-chat-messages/si-chat-input.html new file mode 100644 index 000000000..697330395 --- /dev/null +++ b/src/app/examples/si-chat-messages/si-chat-input.html @@ -0,0 +1,34 @@ +
+ + + + +
diff --git a/src/app/examples/si-chat-messages/si-chat-input.ts b/src/app/examples/si-chat-messages/si-chat-input.ts new file mode 100644 index 000000000..7fec0fdc0 --- /dev/null +++ b/src/app/examples/si-chat-messages/si-chat-input.ts @@ -0,0 +1,104 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +import { Component, inject, signal } from '@angular/core'; +import { + SiChatInputComponent, + MessageAction, + ChatInputAttachment +} from '@siemens/element-ng/chat-messages'; +import { SiIconComponent } from '@siemens/element-ng/icon'; +import { MenuItemAction } from '@siemens/element-ng/menu'; +import { LOG_EVENT } from '@siemens/live-preview'; + +@Component({ + selector: 'app-sample', + imports: [SiChatInputComponent, SiIconComponent], + templateUrl: './si-chat-input.html' +}) +export class SampleComponent { + logEvent = inject(LOG_EVENT); + + readonly inputValue = signal(''); + readonly sending = signal(false); + readonly disabled = signal(false); + readonly interruptible = signal(false); + + actions: MessageAction[] = [ + { + label: 'Take photo', + icon: 'element-camera', + action: () => this.logEvent('Camera clicked') + }, + { + label: 'Text formatting', + icon: 'element-brush', + action: () => this.logEvent('Text formatting clicked') + } + ]; + + secondaryActions: MenuItemAction[] = [ + { + type: 'action', + label: 'Schedule message', + icon: 'element-clock', + action: () => this.logEvent('Schedule clicked') + }, + { + type: 'action', + label: 'Save as draft', + icon: 'element-save', + action: () => this.logEvent('Save draft clicked') + } + ]; + + preAttachedFiles: ChatInputAttachment[] = [ + { + name: 'project-spec.pdf', + size: 1234567, + type: 'application/pdf', + file: new File([''], 'project-spec.pdf', { type: 'application/pdf' }) + }, + { + name: 'mockup.png', + size: 987654, + type: 'image/png', + file: new File([''], 'mockup.png', { type: 'image/png' }) + } + ]; + + onMessageSent(event: { content: string; attachments: ChatInputAttachment[] }): void { + this.logEvent(`Message sent: "${event.content}" with ${event.attachments.length} attachments`); + + this.sending.set(true); + setTimeout(() => { + this.sending.set(false); + }, 2000); + } + + onFileError(error: any): void { + this.logEvent(`File error: ${error.errorText} - ${error.fileName}`); + } + + onInterrupt(): void { + this.logEvent('Interrupt clicked'); + this.sending.set(false); + } + + toggleDisabled(): void { + this.disabled.update(current => !current); + } + + toggleSending(): void { + this.sending.update(current => !current); + } + + setInputValue(value: string): void { + this.inputValue.set(value); + } + + toggleInterruptible(): void { + this.interruptible.update(current => !current); + } +} From 147c7cad227ff214c66a1181e4f00fba231e496d Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Thu, 30 Oct 2025 15:11:42 +0100 Subject: [PATCH 3/3] test(chat-messages): add static VRT for chat-input --- playwright/e2e/element-examples/static.spec.ts | 1 + ...input-element-examples-chromium-dark-linux.png | 3 +++ ...nput-element-examples-chromium-light-linux.png | 3 +++ .../si-chat-messages--si-chat-input.yaml | 15 +++++++++++++++ 4 files changed, 22 insertions(+) create mode 100644 playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-input-element-examples-chromium-dark-linux.png create mode 100644 playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-input-element-examples-chromium-light-linux.png create mode 100644 playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-input.yaml diff --git a/playwright/e2e/element-examples/static.spec.ts b/playwright/e2e/element-examples/static.spec.ts index ae229b662..2fa83acd7 100644 --- a/playwright/e2e/element-examples/static.spec.ts +++ b/playwright/e2e/element-examples/static.spec.ts @@ -114,3 +114,4 @@ test('si-chat-messages/si-ai-message', ({ si }) => si.static()); test('si-chat-messages/si-user-message', ({ si }) => si.static()); test('si-chat-messages/si-chat-message', ({ si }) => si.static()); test('si-chat-messages/si-attachment-list', ({ si }) => si.static()); +test('si-chat-messages/si-chat-input', ({ si }) => si.static()); diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-input-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-input-element-examples-chromium-dark-linux.png new file mode 100644 index 000000000..77812672d --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-input-element-examples-chromium-dark-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:61cadccdf10000f1f1e5482e7c89eda27ed153f6253e696ce078e988b0c1944f +size 15765 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-input-element-examples-chromium-light-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-input-element-examples-chromium-light-linux.png new file mode 100644 index 000000000..070521f26 --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-input-element-examples-chromium-light-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ab962e6a45aa72c200b0312b592b7512c7fd5418b728613197bc4634e9d927e3 +size 15640 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-input.yaml b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-input.yaml new file mode 100644 index 000000000..6b45be157 --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-input.yaml @@ -0,0 +1,15 @@ +- group: + - text: project-spec.pdf + - button "Remove attachment project-spec.pdf" +- group: + - text: mockup.png + - button "Remove attachment mockup.png" +- textbox "Chat message input": + - /placeholder: Enter a command, question or topic... +- button "Attach files" +- button "Take photo" +- button "Text formatting" +- button "More actions" +- button "Insert emoji" +- button "Send Message" +- text: The content is AI generated. Always verify the information for accuracy. \ No newline at end of file