diff --git a/.github/skills/changeset-decision/SKILL.md b/.agents/skills/changeset-decision/SKILL.md similarity index 100% rename from .github/skills/changeset-decision/SKILL.md rename to .agents/skills/changeset-decision/SKILL.md diff --git a/.github/skills/create-changeset/SKILL.md b/.agents/skills/create-changeset/SKILL.md similarity index 100% rename from .github/skills/create-changeset/SKILL.md rename to .agents/skills/create-changeset/SKILL.md diff --git a/.agents/skills/sdd/SKILL.md b/.agents/skills/sdd/SKILL.md new file mode 100644 index 00000000000..85a7ac626c4 --- /dev/null +++ b/.agents/skills/sdd/SKILL.md @@ -0,0 +1,277 @@ +--- +name: sdd +description: >- + Spec-Driven Development for Siemens IX: turns user-provided issue or tracking text + into spec.md, plan.md, and tasks.md (GitHub spec-kit style, size-aware, no tracker integration). + Use when the user asks for SDD, spec-kit planning, or structured feature docs from issue text + (e.g. IX-1234 plus pasted description). Outputs live under .sdd/{tracking-id}/. +argument-hint: 'Tracking/issue id (e.g. IX-3896) and pasted title, description, acceptance criteria' +user-invocable: true +--- + +# Spec-Driven Development (IX) + +## Overview + +Transform **user-provided issue or tracking text** (no tracker integration required) into structured specifications following **GitHub spec-kit methodology**: clear separation, progressive disclosure, minimal duplication. + +**When to use**: User wants SDD / spec planning for an IX change and supplies (or will supply) a tracking/issue identifier plus title, description, and acceptance criteria as plain text. + +## Required input (no tracker integration) + +Before Phase 1, confirm you have: + +1. **Tracking/issue identifier** (e.g. `IX-3896`, issue number, ticket key) — if missing, ask once; if still missing, derive a short folder slug from the title (lowercase, hyphens, max ~40 chars). +2. **Issue title** (summary line). +3. **Description** — user story, context, bullets, links as pasted. +4. **Acceptance criteria** — pasted AC block or checklist from the issue/tracking source. + +If the user only pastes prose without clear AC, extract implied AC into the spec and add a **Clarifications** note listing assumptions. + +**Do not** call tracker APIs or assume an MCP; treat the chat message as the source of truth. + +## Key principles + +1. **Separation**: `spec.md` (WHAT), `plan.md` (HOW), `tasks.md` (DO) — no duplication. +2. **Tech-agnostic specs**: Requirements do not name Stencil/React/etc. +3. **Size-aware output**: XS compact; L fuller (see below). +4. **Line limits**: Each artifact file under 500 lines. +5. **Progressive disclosure**: Read spec → plan → tasks when implementing. + +## Workflow phases + +``` +Pasted issue text → Specify → Review → Clarify → Review → Plan → Review → Tasks → Review → Implement + ↓ ↓ ↓ ↓ + spec.md spec.md plan.md tasks.md +``` + +**Mandatory phase gates**: + +After creating or updating each artifact, stop and ask the user to review the created file before continuing: + +1. After `spec.md`: ask whether to continue to planning or revise the spec. +2. After updated `spec.md` clarifications: ask whether to continue to planning or revise the clarified spec. +3. After `plan.md`: ask whether to continue to task breakdown or revise the plan. +4. After `tasks.md`: ask whether to implement, revise tasks, or stop at handoff. + +Do **not** proceed to the next phase automatically after writing an artifact. If the runtime provides an `ask_user` tool, use it for these phase gates instead of asking in plain text. + +If the XS strategy omits `spec.md` or `tasks.md`, skip only the omitted file's review gate; still stop after the merged `plan.md` and ask the user to review it before implementation. + +### Phase 1: Specify (requirements) + +Create `spec.md` from the pasted issue only (optional: spawn a read-only explore subagent for codebase context **after** spec is drafted if gaps exist — do not block spec on repo-wide search). + +**Output path**: `.sdd/{TRACKING_ID}/spec.md` (use the agreed tracking/issue identifier or slug for `{TRACKING_ID}`). + +**Review gate**: After writing `spec.md`, stop and ask the user to review `.sdd/{TRACKING_ID}/spec.md`. Continue only when the user approves moving to planning; otherwise revise `spec.md` first. + +**Frontmatter**: + +```yaml +--- +tracking_id: {ID or slug} +feature: {TITLE} +date: {ISO date} +status: ready-for-planning +source: user-pasted-issue +--- +``` + +**Body template** (adapt length to size tier): + +```markdown +# Feature Spec: {TITLE} + +## User input (from issue) +{2–5 lines summarizing pasted description — no copy-paste dump of entire ticket} + +## User scenarios and testing + +### US1 [P1] - {Scenario} +**As** {role} +**I want** {goal} +**So that** {benefit} + +**Acceptance scenarios:** +**Given** {context} +**When** {action} +**Then** {outcome} + +**Edge cases:** {if any} + +## Requirements + +### Functional requirements +**FR-001**: {requirement} +**FR-002**: {requirement} + +### Key entities +{Only if data or domain models matter} + +## Success criteria +**SC-001**: {measurable outcome} +**SC-002**: {measurable outcome} + +## Assumptions +{Scope boundaries, dependencies on other IX areas} +``` + +**Size guide (spec.md only)**: + +- XS: ~40 lines +- S: ~80 lines +- M: ~100 lines +- L: ~150 lines + +### Phase 2: Clarify (optional) + +If requirements are ambiguous, ask targeted questions in chat; when answered, append to `spec.md`: + +```markdown +## Clarifications + +**Q1**: … +**A**: … +``` + +**Review gate**: After adding clarifications, stop and ask the user to review the updated `.sdd/{TRACKING_ID}/spec.md`. Continue only when the user approves moving to planning; otherwise revise `spec.md` first. + +### Phase 3: Plan (technical approach) + +**Output**: `.sdd/{TRACKING_ID}/plan.md` + +1. Read `spec.md`. +2. Align with IX repo: skim `CONTRIBUTING.md` and relevant package/component paths touched; follow existing patterns in the monorepo. +3. Record technical decisions with short rationale; **do not** repeat requirements from `spec.md` — reference them (`See spec.md US1`). + +**Plan frontmatter**: + +```yaml +--- +tracking_id: {ID or slug} +phase: plan +created: {ISO date} +depends_on: [] +--- +``` + +**Sections**: Summary, Technical context (language, packages, tests), Architecture (decisions with alternatives rejected), Implementation structure, Risks and mitigations, Deployment strategy only if non-trivial. + +**Review gate**: After writing `plan.md`, stop and ask the user to review `.sdd/{TRACKING_ID}/plan.md`. Continue only when the user approves creating tasks; otherwise revise `plan.md` first. + +**Size guide (plan.md)**: + +- XS: merge spec + plan into a **single** `plan.md` only (~80 lines total) — skip separate `spec.md` only when doing XS merge (see XS strategy below). +- S: ~120 lines +- M: ~150 lines +- L: ~200 lines + +### Phase 4: Tasks (implementation steps) + +**Output**: `.sdd/{TRACKING_ID}/tasks.md` + +Ordered steps; each task: id, file path in repo, action, optional snippet, dependencies. Mark parallelizable tasks with `[P]`. Do not repeat plan rationale. + +**Review gate**: After writing `tasks.md`, stop and ask the user to review `.sdd/{TRACKING_ID}/tasks.md`. Implement only if the user explicitly approves implementation; otherwise revise `tasks.md` or stop at handoff. + +**Size guide (tasks.md)**: + +- XS: omit `tasks.md` (implement from merged plan). +- S: ~100 lines +- M: ~150 lines +- L: ~200 lines + +### Phase 5: Research (optional) + +Create `research.md` only for unusual dependency or API verification — not for routine Stencil/Playwright usage. + +**Output**: `.sdd/{TRACKING_ID}/research.md` (<300 lines). + +## XS output strategy + +For **XS** (<~2h) work: + +- Produce **one** file: `.sdd/{TRACKING_ID}/plan.md` (~80 lines) that merges concise requirements + approach + minimal task list + validation checklist. +- Omit separate `spec.md` / `tasks.md` unless the user explicitly wants full spec-kit layout. +- Stop after writing the merged `plan.md` and ask the user to review it before implementation. + +## S / M / L output strategy + +| Tier | Outputs | Approx. total lines | +|------|---------|---------------------| +| S | spec.md + plan.md + tasks.md | ~300 | +| M | spec.md + plan.md + tasks.md | ~400 | +| L | spec.md + plan.md + tasks.md (+ optional research.md) | ~600 | + +Classify size from pasted story points, AC count, and breadth (single component vs cross-package). + +## Anti-duplication rules + +Before each paragraph: + +1. Already in `spec.md`? → Link, do not repeat. +2. Technical decision? → `plan.md` only. +3. Implementation step? → `tasks.md` only. +4. Obvious to an IX maintainer? → Skip (standard monorepo test commands, changeset rules — point to existing skills if needed). +5. Project-wide rule? → Reference `CONTRIBUTING.md` or the relevant package README. + +**Cross-reference pattern**: + +```markdown +See spec.md US1 for acceptance criteria. +See plan.md Architecture for rationale. +``` + +## File layout + +```text +.sdd/{TRACKING_ID}/ +├── spec.md # Requirements (tech-agnostic) +├── plan.md # Technical approach +├── tasks.md # Implementation steps +└── research.md # Optional tech verification +``` + +Do **not** generate `narrative.md` or standalone `alternatives.md` — keep decisions inline in `plan.md`. + +## Context management + +- **Main chat**: clarify scope, write `plan.md` / `tasks.md`, discuss trade-offs. +- **Read-only exploration**: large codebase search to ground `plan.md` paths — use sparingly after `spec.md` exists. + +## Implementation handoff + +When artifacts are ready: + +```text +Specification complete for {TRACKING_ID}. + +Files: +- spec.md — requirements +- plan.md — technical approach +- tasks.md — steps (if created) + +Next: implement a task, all tasks in order, or revise the plan — say which you prefer. +``` + +## Quality checklist + +**spec.md**: Tech-agnostic; Given/When/Then where applicable; numbered FRs; measurable success criteria; <500 lines. + +**plan.md**: No requirement duplication; decisions with 1–2 line rationale; references spec; IX-realistic paths; <500 lines. + +**tasks.md**: Exact repo paths; dependency order; `[P]` for parallel; no decision essays; <500 lines. + +**Overall**: No cross-file duplication; size tier matches work; each file has one job. + +## IX-specific notes + +- Prefer paths under `packages/core`, `packages/angular`, `packages/react`, `packages/documentation`, etc., as appropriate — discover real paths from the tree, do not invent directories. +- Testing and release hygiene: use existing project skills (component tests, Storybook a11y, changesets) when the user’s workflow requires them; do not restate full skill bodies here. + +## References + +- [GitHub spec-kit](https://github.com/github/spec-kit) +- Project skill definition: `.github/skills/sdd/SKILL.md` — this repo skill uses pasted issue/tracking text and `.sdd/…` for generated artifacts. diff --git a/.changeset/chat-input-component.md b/.changeset/chat-input-component.md new file mode 100644 index 00000000000..4b553b8f5a3 --- /dev/null +++ b/.changeset/chat-input-component.md @@ -0,0 +1,5 @@ +--- +'@siemens/ix': minor +--- + +Add the `ix-chat-input` component for AI prompt entry, including multiline input behavior, attachment support with `ix-chat-prompt-attachment`, action slots, submit handling, processing state, threshold-based character limit guidance, form integration, and disclaimer text. diff --git a/.changeset/chat-user-message-component.md b/.changeset/chat-user-message-component.md new file mode 100644 index 00000000000..c6da0e827e6 --- /dev/null +++ b/.changeset/chat-user-message-component.md @@ -0,0 +1,5 @@ +--- +'@siemens/ix': minor +--- + +Add the `ix-chat` component for composing chat layouts with messages and a chat input, add the `ix-chat-user-message` component for displaying right-aligned user messages in chat interfaces with optional contextual actions and sent attachments, and add the `ix-chat-ai-message` component for displaying slotted AI-generated responses with optional actions and sources. The existing `ix-chat-prompt-attachment` component now renders with `ix-chip`, supports a compact `sent` variant, supports preview-enabled attachments with `previewSupported`, emits `attachmentClick` only for those preview interactions, and no longer exposes a retry action for failed attachments. `ix-chat-input` can display prompt attachments and now provides a `follow-up` slot for follow-up prompt actions. diff --git a/.changeset/chip-inactive-close-button.md b/.changeset/chip-inactive-close-button.md new file mode 100644 index 00000000000..71ca52d7dd8 --- /dev/null +++ b/.changeset/chip-inactive-close-button.md @@ -0,0 +1,5 @@ +--- +'@siemens/ix': patch +--- + +Fix `ix-chip` so closable chips still render an interactive close button when `inactive` is set without overlapping or breaking text truncation. diff --git a/.gitignore b/.gitignore index b464aa7dd0f..2f4f39ff51d 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ node_modules # misc .turbo +/.sdd/ /.sass-cache /connect.lock /coverage diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fdb45fd2d17..259be4c381d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -75,6 +75,14 @@ Supporting roots: | Generate Changeset | `pnpm changeset` | Add semver intent (patch/minor/major) | | Docs Sync (examples) | Build + run docs pipeline | Examples auto-generated from test apps | +### Framework Tests and Preview Examples + +`packages/html-test-app/src/preview-examples/*.html` is the source list for framework test IDs. When adding or renaming an HTML preview example, run `pnpm --filter framework-tests build` from the repository root before adding or validating matching Angular, React, or Vue preview variants. + +The build regenerates files under `testing/framework-tests/tests/generated/`, including `test-ids.autogenerated.ts`, snapshot tests, axe tests, and generated indexes. Do not hand-edit generated files; update `testing/framework-tests/src/main.ts` instead if generation behavior needs to change. + +Preview example changes can affect accessibility tree snapshots in `testing/framework-tests/__snapshots__/*.yaml`. Add or update the matching `*-aria-snapshot.yaml` files when a generated snapshot test changes. + ### Stencil Build Outputs - `dist/` bundles diff --git a/package.json b/package.json index 938fb341ab5..0b5ade4aaed 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ "test.setup": "dotenv -- turbo run test.setup --concurrency=1", "prepare": "pnpm disable-telemetry", "disable-telemetry": "turbo telemetry disable", - "compare-examples": "node ./scripts/compare-preview-examples-across-test-apps.js", "ci:version": "pnpm changeset version && pnpm i --lockfile-only", "ci:publish": "pnpm changeset publish", "ts-run": "tsx" diff --git a/packages/angular-standalone-test-app/src/app/app.routes.ts b/packages/angular-standalone-test-app/src/app/app.routes.ts index cfc4e9e9af6..2ac935e3710 100644 --- a/packages/angular-standalone-test-app/src/app/app.routes.ts +++ b/packages/angular-standalone-test-app/src/app/app.routes.ts @@ -98,6 +98,14 @@ export const routePaths: IxPreviewRoutes = { import('../preview-examples/category-filter-suggestions').then( (m) => m.default ), + 'preview/chat': () => + import('../preview-examples/chat').then((m) => m.default), + 'preview/chat-ai-message': () => + import('../preview-examples/chat-ai-message').then((m) => m.default), + 'preview/chat-input': () => + import('../preview-examples/chat-input').then((m) => m.default), + 'preview/chat-user-message': () => + import('../preview-examples/chat-user-message').then((m) => m.default), 'preview/checkbox': () => import('../preview-examples/checkbox').then((m) => m.default), 'preview/checkbox-indeterminate': () => @@ -123,9 +131,7 @@ export const routePaths: IxPreviewRoutes = { 'preview/date-dropdown': () => import('../preview-examples/date-dropdown').then((m) => m.default), 'preview/date-dropdown-presets': () => - import('../preview-examples/date-dropdown-presets').then( - (m) => m.default - ), + import('../preview-examples/date-dropdown-presets').then((m) => m.default), 'preview/date-range': () => import('../preview-examples/date-range').then((m) => m.default), 'preview/datetime-range': () => diff --git a/packages/angular-standalone-test-app/src/preview-examples/chat-ai-message.html b/packages/angular-standalone-test-app/src/preview-examples/chat-ai-message.html new file mode 100644 index 00000000000..ad416157fc1 --- /dev/null +++ b/packages/angular-standalone-test-app/src/preview-examples/chat-ai-message.html @@ -0,0 +1,55 @@ + + + + +

Recommended maintenance actions

+

+ The compressor is operating within the expected range. Schedule the next + inspection during the Friday service window and keep monitoring the + vibration trend. +

+ + + + + + + Sources + +
+
diff --git a/packages/angular-standalone-test-app/src/preview-examples/chat-ai-message.ts b/packages/angular-standalone-test-app/src/preview-examples/chat-ai-message.ts new file mode 100644 index 00000000000..2228a54d5c2 --- /dev/null +++ b/packages/angular-standalone-test-app/src/preview-examples/chat-ai-message.ts @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Component } from '@angular/core'; +import { + IxChat, + IxChatAiMessage, + IxChip, + IxIconButton, +} from '@siemens/ix-angular/standalone'; +import { addIcons } from '@siemens/ix-icons'; +import { + iconCopy, + iconGlobe, + iconRefresh, + iconThumbDown, + iconThumbUp, +} from '@siemens/ix-icons/icons'; + +@Component({ + selector: 'app-example', + templateUrl: './chat-ai-message.html', + imports: [IxChat, IxChatAiMessage, IxChip, IxIconButton], +}) +export default class ChatAiMessage { + constructor() { + addIcons({ + iconCopy, + iconGlobe, + iconRefresh, + iconThumbDown, + iconThumbUp, + }); + } +} diff --git a/packages/angular-standalone-test-app/src/preview-examples/chat-input.html b/packages/angular-standalone-test-app/src/preview-examples/chat-input.html new file mode 100644 index 00000000000..0f3f95f58d2 --- /dev/null +++ b/packages/angular-standalone-test-app/src/preview-examples/chat-input.html @@ -0,0 +1,46 @@ + + + + + + + + + + diff --git a/packages/angular-standalone-test-app/src/preview-examples/chat-input.ts b/packages/angular-standalone-test-app/src/preview-examples/chat-input.ts new file mode 100644 index 00000000000..d0d45be3fc0 --- /dev/null +++ b/packages/angular-standalone-test-app/src/preview-examples/chat-input.ts @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Component } from '@angular/core'; +import { + IxChat, + IxChatInput, + IxChatPromptAttachment, + IxIconButton, +} from '@siemens/ix-angular/standalone'; +import { addIcons } from '@siemens/ix-icons'; +import { + iconAttach, + iconImage, + iconMicrophone, + iconPdfDocument, +} from '@siemens/ix-icons/icons'; + +@Component({ + selector: 'app-example', + templateUrl: './chat-input.html', + imports: [IxChat, IxChatInput, IxChatPromptAttachment, IxIconButton], +}) +export default class ChatInput { + constructor() { + addIcons({ + iconAttach, + iconImage, + iconMicrophone, + iconPdfDocument, + }); + } +} diff --git a/packages/angular-standalone-test-app/src/preview-examples/chat-user-message.html b/packages/angular-standalone-test-app/src/preview-examples/chat-user-message.html new file mode 100644 index 00000000000..1532c2470ff --- /dev/null +++ b/packages/angular-standalone-test-app/src/preview-examples/chat-user-message.html @@ -0,0 +1,46 @@ + + + + + + + + + + + diff --git a/packages/angular-standalone-test-app/src/preview-examples/chat-user-message.ts b/packages/angular-standalone-test-app/src/preview-examples/chat-user-message.ts new file mode 100644 index 00000000000..dc4bc81090a --- /dev/null +++ b/packages/angular-standalone-test-app/src/preview-examples/chat-user-message.ts @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Component } from '@angular/core'; +import { + IxChat, + IxChatPromptAttachment, + IxChatUserMessage, + IxIconButton, +} from '@siemens/ix-angular/standalone'; +import { addIcons } from '@siemens/ix-icons'; +import { + iconCopy, + iconMoreMenu, + iconPdfDocument, + iconPen, + iconTxtDocument, +} from '@siemens/ix-icons/icons'; + +@Component({ + selector: 'app-example', + templateUrl: './chat-user-message.html', + imports: [IxChat, IxChatPromptAttachment, IxChatUserMessage, IxIconButton], +}) +export default class ChatUserMessage { + constructor() { + addIcons({ + iconCopy, + iconMoreMenu, + iconPdfDocument, + iconPen, + iconTxtDocument, + }); + } +} diff --git a/packages/angular-standalone-test-app/src/preview-examples/chat.html b/packages/angular-standalone-test-app/src/preview-examples/chat.html new file mode 100644 index 00000000000..3b411a67615 --- /dev/null +++ b/packages/angular-standalone-test-app/src/preview-examples/chat.html @@ -0,0 +1,117 @@ + + + + + + + + + + + + +

Asset performance summary

+

+ The compressor train is operating inside the expected range. Vibration + increased slightly after the last shift change, but all values remain + below the warning threshold. +

+ + + + + + + Sources + +
+ + + + + + +

+ Customer feedback confirms the same maintenance priorities. The next step + is to validate spare-part availability and confirm the planned downtime + window. +

+
+ + +
diff --git a/packages/angular-standalone-test-app/src/preview-examples/chat.ts b/packages/angular-standalone-test-app/src/preview-examples/chat.ts new file mode 100644 index 00000000000..a959822995c --- /dev/null +++ b/packages/angular-standalone-test-app/src/preview-examples/chat.ts @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Component } from '@angular/core'; +import { + IxChat, + IxChatAiMessage, + IxChatInput, + IxChatPromptAttachment, + IxChatUserMessage, + IxChip, + IxIconButton, +} from '@siemens/ix-angular/standalone'; +import { addIcons } from '@siemens/ix-icons'; +import { + iconCopy, + iconGlobe, + iconMoreMenu, + iconPdfDocument, + iconPen, + iconRefresh, + iconThumbDown, + iconThumbUp, + iconTxtDocument, +} from '@siemens/ix-icons/icons'; + +@Component({ + selector: 'app-example', + templateUrl: './chat.html', + imports: [ + IxChat, + IxChatAiMessage, + IxChatInput, + IxChatPromptAttachment, + IxChatUserMessage, + IxChip, + IxIconButton, + ], +}) +export default class Chat { + constructor() { + addIcons({ + iconCopy, + iconGlobe, + iconMoreMenu, + iconPdfDocument, + iconPen, + iconRefresh, + iconThumbDown, + iconThumbUp, + iconTxtDocument, + }); + } +} diff --git a/packages/angular-test-app/src/app/app-routing.module.ts b/packages/angular-test-app/src/app/app-routing.module.ts index 4c527201f37..8edea4a6934 100644 --- a/packages/angular-test-app/src/app/app-routing.module.ts +++ b/packages/angular-test-app/src/app/app-routing.module.ts @@ -47,6 +47,10 @@ import Card from '../preview-examples/card'; import CardList from '../preview-examples/card-list'; import CategoryFilter from '../preview-examples/category-filter'; import CategoryFilterSuggestions from '../preview-examples/category-filter-suggestions'; +import Chat from '../preview-examples/chat'; +import ChatAiMessage from '../preview-examples/chat-ai-message'; +import ChatInput from '../preview-examples/chat-input'; +import ChatUserMessage from '../preview-examples/chat-user-message'; import Checkbox from '../preview-examples/checkbox'; import CheckboxIndeterminate from '../preview-examples/checkbox-indeterminate'; import Chip from '../preview-examples/chip'; @@ -417,6 +421,22 @@ const routes: Routes = [ path: 'category-filter', component: CategoryFilter, }, + { + path: 'chat', + component: Chat, + }, + { + path: 'chat-ai-message', + component: ChatAiMessage, + }, + { + path: 'chat-input', + component: ChatInput, + }, + { + path: 'chat-user-message', + component: ChatUserMessage, + }, { path: 'checkbox-indeterminate', component: CheckboxIndeterminate, diff --git a/packages/angular-test-app/src/app/app.module.ts b/packages/angular-test-app/src/app/app.module.ts index 20fed4fbb1a..10da935d48a 100644 --- a/packages/angular-test-app/src/app/app.module.ts +++ b/packages/angular-test-app/src/app/app.module.ts @@ -56,6 +56,10 @@ import Card from '../preview-examples/card'; import CardList from '../preview-examples/card-list'; import CategoryFilter from '../preview-examples/category-filter'; import CategoryFilterSuggestions from '../preview-examples/category-filter-suggestions'; +import Chat from '../preview-examples/chat'; +import ChatAiMessage from '../preview-examples/chat-ai-message'; +import ChatInput from '../preview-examples/chat-input'; +import ChatUserMessage from '../preview-examples/chat-user-message'; import Checkbox from '../preview-examples/checkbox'; import CheckboxIndeterminate from '../preview-examples/checkbox-indeterminate'; import Chip from '../preview-examples/chip'; @@ -311,6 +315,10 @@ import WorkflowVertical from '../preview-examples/workflow-vertical'; CardList, CategoryFilterSuggestions, CategoryFilter, + Chat, + ChatAiMessage, + ChatInput, + ChatUserMessage, CheckboxIndeterminate, Checkbox, Chip, diff --git a/packages/angular-test-app/src/preview-examples/chat-ai-message.html b/packages/angular-test-app/src/preview-examples/chat-ai-message.html new file mode 100644 index 00000000000..ad416157fc1 --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/chat-ai-message.html @@ -0,0 +1,55 @@ + + + + +

Recommended maintenance actions

+

+ The compressor is operating within the expected range. Schedule the next + inspection during the Friday service window and keep monitoring the + vibration trend. +

+ + + + + + + Sources + +
+
diff --git a/packages/angular-test-app/src/preview-examples/chat-ai-message.ts b/packages/angular-test-app/src/preview-examples/chat-ai-message.ts new file mode 100644 index 00000000000..ff9e28f4847 --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/chat-ai-message.ts @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Component } from '@angular/core'; +import { addIcons } from '@siemens/ix-icons'; +import { + iconCopy, + iconGlobe, + iconRefresh, + iconThumbDown, + iconThumbUp, +} from '@siemens/ix-icons/icons'; + +@Component({ + standalone: false, + selector: 'app-example', + templateUrl: './chat-ai-message.html', +}) +export default class ChatAiMessage { + constructor() { + addIcons({ + iconCopy, + iconGlobe, + iconRefresh, + iconThumbDown, + iconThumbUp, + }); + } +} diff --git a/packages/angular-test-app/src/preview-examples/chat-input.html b/packages/angular-test-app/src/preview-examples/chat-input.html new file mode 100644 index 00000000000..d7792c799d0 --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/chat-input.html @@ -0,0 +1,46 @@ + + + + + + + + + + diff --git a/packages/angular-test-app/src/preview-examples/chat-input.ts b/packages/angular-test-app/src/preview-examples/chat-input.ts new file mode 100644 index 00000000000..54f39055f73 --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/chat-input.ts @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Component } from '@angular/core'; +import { addIcons } from '@siemens/ix-icons'; +import { + iconImage, + iconMicrophone, + iconPdfDocument, + iconPlus, +} from '@siemens/ix-icons/icons'; + +@Component({ + standalone: false, + selector: 'app-example', + templateUrl: './chat-input.html', +}) +export default class ChatInput { + constructor() { + addIcons({ + iconImage, + iconMicrophone, + iconPdfDocument, + iconPlus, + }); + } +} diff --git a/packages/angular-test-app/src/preview-examples/chat-user-message.html b/packages/angular-test-app/src/preview-examples/chat-user-message.html new file mode 100644 index 00000000000..1532c2470ff --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/chat-user-message.html @@ -0,0 +1,46 @@ + + + + + + + + + + + diff --git a/packages/angular-test-app/src/preview-examples/chat-user-message.ts b/packages/angular-test-app/src/preview-examples/chat-user-message.ts new file mode 100644 index 00000000000..8aa46f061a9 --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/chat-user-message.ts @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Component } from '@angular/core'; +import { addIcons } from '@siemens/ix-icons'; +import { + iconCopy, + iconMoreMenu, + iconPdfDocument, + iconPen, + iconTxtDocument, +} from '@siemens/ix-icons/icons'; + +@Component({ + standalone: false, + selector: 'app-example', + templateUrl: './chat-user-message.html', +}) +export default class ChatUserMessage { + constructor() { + addIcons({ + iconCopy, + iconMoreMenu, + iconPdfDocument, + iconPen, + iconTxtDocument, + }); + } +} diff --git a/packages/angular-test-app/src/preview-examples/chat.html b/packages/angular-test-app/src/preview-examples/chat.html new file mode 100644 index 00000000000..3b411a67615 --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/chat.html @@ -0,0 +1,117 @@ + + + + + + + + + + + + +

Asset performance summary

+

+ The compressor train is operating inside the expected range. Vibration + increased slightly after the last shift change, but all values remain + below the warning threshold. +

+ + + + + + + Sources + +
+ + + + + + +

+ Customer feedback confirms the same maintenance priorities. The next step + is to validate spare-part availability and confirm the planned downtime + window. +

+
+ + +
diff --git a/packages/angular-test-app/src/preview-examples/chat.ts b/packages/angular-test-app/src/preview-examples/chat.ts new file mode 100644 index 00000000000..7ced38e1525 --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/chat.ts @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Component } from '@angular/core'; +import { addIcons } from '@siemens/ix-icons'; +import { + iconCopy, + iconGlobe, + iconMoreMenu, + iconPdfDocument, + iconPen, + iconRefresh, + iconThumbDown, + iconThumbUp, + iconTxtDocument, +} from '@siemens/ix-icons/icons'; + +@Component({ + standalone: false, + selector: 'app-example', + templateUrl: './chat.html', +}) +export default class Chat { + constructor() { + addIcons({ + iconCopy, + iconGlobe, + iconMoreMenu, + iconPdfDocument, + iconPen, + iconRefresh, + iconThumbDown, + iconThumbUp, + iconTxtDocument, + }); + } +} diff --git a/packages/angular/src/components.ts b/packages/angular/src/components.ts index e78248a9ba0..7909f1f0309 100644 --- a/packages/angular/src/components.ts +++ b/packages/angular/src/components.ts @@ -402,6 +402,154 @@ export declare interface IxCategoryFilter extends Components.IxCategoryFilter { } +@ProxyCmp({ +}) +@Component({ + selector: 'ix-chat', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: [], + standalone: false +}) +export class IxChat { + protected el: HTMLIxChatElement; + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + } +} + + +export declare interface IxChat extends Components.IxChat {} + + +@ProxyCmp({ +}) +@Component({ + selector: 'ix-chat-ai-message', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: [], + standalone: false +}) +export class IxChatAiMessage { + protected el: HTMLIxChatAiMessageElement; + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + } +} + + +export declare interface IxChatAiMessage extends Components.IxChatAiMessage {} + + +@ProxyCmp({ + inputs: ['characterLimit', 'characterLimitWarningThreshold', 'disabled', 'disclaimer', 'i18nCharacterLimitReached', 'i18nCharacterLimitWarning', 'insertLineBreakOnEnter', 'maxLength', 'maxRows', 'minRows', 'name', 'placeholder', 'readonly', 'state', 'textareaLabel', 'value'], + methods: ['getNativeInputElement', 'focusInput'] +}) +@Component({ + selector: 'ix-chat-input', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: ['characterLimit', 'characterLimitWarningThreshold', 'disabled', 'disclaimer', 'i18nCharacterLimitReached', 'i18nCharacterLimitWarning', 'insertLineBreakOnEnter', 'maxLength', 'maxRows', 'minRows', 'name', 'placeholder', 'readonly', 'state', 'textareaLabel', 'value'], + outputs: ['valueChange', 'ixBlur', 'ixChange', 'promptSubmit'], + standalone: false +}) +export class IxChatInput { + protected el: HTMLIxChatInputElement; + @Output() valueChange = new EventEmitter>(); + @Output() ixBlur = new EventEmitter>(); + @Output() ixChange = new EventEmitter>(); + @Output() promptSubmit = new EventEmitter>(); + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + } +} + + +export declare interface IxChatInput extends Components.IxChatInput { + /** + * Event emitted when the value of the chat input changes. @since 5.1.0 + */ + valueChange: EventEmitter>; + /** + * Event emitted when the chat input loses focus. @since 5.1.0 + */ + ixBlur: EventEmitter>; + /** + * Event emitted when the chat input loses focus and the value has changed. @since 5.1.0 + */ + ixChange: EventEmitter>; + /** + * Event emitted when the prompt is submitted by the send button or Enter key. @since 5.1.0 + */ + promptSubmit: EventEmitter>; +} + + +@ProxyCmp({ + inputs: ['fileName', 'hideRemoveButton', 'icon', 'previewSupported', 'removeAriaLabel', 'status'] +}) +@Component({ + selector: 'ix-chat-prompt-attachment', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: ['fileName', 'hideRemoveButton', 'icon', 'previewSupported', 'removeAriaLabel', 'status'], + outputs: ['attachmentClick', 'removeClick'], + standalone: false +}) +export class IxChatPromptAttachment { + protected el: HTMLIxChatPromptAttachmentElement; + @Output() attachmentClick = new EventEmitter>(); + @Output() removeClick = new EventEmitter>(); + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + } +} + + +export declare interface IxChatPromptAttachment extends Components.IxChatPromptAttachment { + /** + * Event emitted when the attachment is clicked. @since 5.1.0 + */ + attachmentClick: EventEmitter>; + /** + * Event emitted when the remove action is clicked. @since 5.1.0 + */ + removeClick: EventEmitter>; +} + + +@ProxyCmp({ + inputs: ['message'] +}) +@Component({ + selector: 'ix-chat-user-message', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: ['message'], + standalone: false +}) +export class IxChatUserMessage { + protected el: HTMLIxChatUserMessageElement; + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + } +} + + +export declare interface IxChatUserMessage extends Components.IxChatUserMessage {} + + @ProxyCmp({ inputs: ['checked', 'disabled', 'indeterminate', 'label', 'name', 'required', 'value'] }) diff --git a/packages/angular/src/declare-components.ts b/packages/angular/src/declare-components.ts index 289d29e903a..9427058fde4 100644 --- a/packages/angular/src/declare-components.ts +++ b/packages/angular/src/declare-components.ts @@ -16,6 +16,11 @@ export const DIRECTIVES = [ d.IxCardList, d.IxCardTitle, d.IxCategoryFilter, + d.IxChat, + d.IxChatAiMessage, + d.IxChatInput, + d.IxChatPromptAttachment, + d.IxChatUserMessage, d.IxCheckbox, d.IxCheckboxGroup, d.IxChip, diff --git a/packages/angular/standalone/src/components.ts b/packages/angular/standalone/src/components.ts index 71ded1eea1c..2f64ab8d056 100644 --- a/packages/angular/standalone/src/components.ts +++ b/packages/angular/standalone/src/components.ts @@ -20,6 +20,11 @@ import { defineCustomElement as defineIxCardContent } from '@siemens/ix/componen import { defineCustomElement as defineIxCardList } from '@siemens/ix/components/ix-card-list.js'; import { defineCustomElement as defineIxCardTitle } from '@siemens/ix/components/ix-card-title.js'; import { defineCustomElement as defineIxCategoryFilter } from '@siemens/ix/components/ix-category-filter.js'; +import { defineCustomElement as defineIxChat } from '@siemens/ix/components/ix-chat.js'; +import { defineCustomElement as defineIxChatAiMessage } from '@siemens/ix/components/ix-chat-ai-message.js'; +import { defineCustomElement as defineIxChatInput } from '@siemens/ix/components/ix-chat-input.js'; +import { defineCustomElement as defineIxChatPromptAttachment } from '@siemens/ix/components/ix-chat-prompt-attachment.js'; +import { defineCustomElement as defineIxChatUserMessage } from '@siemens/ix/components/ix-chat-user-message.js'; import { defineCustomElement as defineIxCheckbox } from '@siemens/ix/components/ix-checkbox.js'; import { defineCustomElement as defineIxCheckboxGroup } from '@siemens/ix/components/ix-checkbox-group.js'; import { defineCustomElement as defineIxChip } from '@siemens/ix/components/ix-chip.js'; @@ -501,6 +506,154 @@ export declare interface IxCategoryFilter extends Components.IxCategoryFilter { } +@ProxyCmp({ + defineCustomElementFn: defineIxChat +}) +@Component({ + selector: 'ix-chat', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: [], +}) +export class IxChat { + protected el: HTMLIxChatElement; + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + } +} + + +export declare interface IxChat extends Components.IxChat {} + + +@ProxyCmp({ + defineCustomElementFn: defineIxChatAiMessage +}) +@Component({ + selector: 'ix-chat-ai-message', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: [], +}) +export class IxChatAiMessage { + protected el: HTMLIxChatAiMessageElement; + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + } +} + + +export declare interface IxChatAiMessage extends Components.IxChatAiMessage {} + + +@ProxyCmp({ + defineCustomElementFn: defineIxChatInput, + inputs: ['characterLimit', 'characterLimitWarningThreshold', 'disabled', 'disclaimer', 'i18nCharacterLimitReached', 'i18nCharacterLimitWarning', 'insertLineBreakOnEnter', 'maxLength', 'maxRows', 'minRows', 'name', 'placeholder', 'readonly', 'state', 'textareaLabel', 'value'], + methods: ['getNativeInputElement', 'focusInput'] +}) +@Component({ + selector: 'ix-chat-input', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: ['characterLimit', 'characterLimitWarningThreshold', 'disabled', 'disclaimer', 'i18nCharacterLimitReached', 'i18nCharacterLimitWarning', 'insertLineBreakOnEnter', 'maxLength', 'maxRows', 'minRows', 'name', 'placeholder', 'readonly', 'state', 'textareaLabel', 'value'], + outputs: ['valueChange', 'ixBlur', 'ixChange', 'promptSubmit'], +}) +export class IxChatInput { + protected el: HTMLIxChatInputElement; + @Output() valueChange = new EventEmitter>(); + @Output() ixBlur = new EventEmitter>(); + @Output() ixChange = new EventEmitter>(); + @Output() promptSubmit = new EventEmitter>(); + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + } +} + + +export declare interface IxChatInput extends Components.IxChatInput { + /** + * Event emitted when the value of the chat input changes. @since 5.1.0 + */ + valueChange: EventEmitter>; + /** + * Event emitted when the chat input loses focus. @since 5.1.0 + */ + ixBlur: EventEmitter>; + /** + * Event emitted when the chat input loses focus and the value has changed. @since 5.1.0 + */ + ixChange: EventEmitter>; + /** + * Event emitted when the prompt is submitted by the send button or Enter key. @since 5.1.0 + */ + promptSubmit: EventEmitter>; +} + + +@ProxyCmp({ + defineCustomElementFn: defineIxChatPromptAttachment, + inputs: ['fileName', 'hideRemoveButton', 'icon', 'previewSupported', 'removeAriaLabel', 'status'] +}) +@Component({ + selector: 'ix-chat-prompt-attachment', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: ['fileName', 'hideRemoveButton', 'icon', 'previewSupported', 'removeAriaLabel', 'status'], + outputs: ['attachmentClick', 'removeClick'], +}) +export class IxChatPromptAttachment { + protected el: HTMLIxChatPromptAttachmentElement; + @Output() attachmentClick = new EventEmitter>(); + @Output() removeClick = new EventEmitter>(); + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + } +} + + +export declare interface IxChatPromptAttachment extends Components.IxChatPromptAttachment { + /** + * Event emitted when the attachment is clicked. @since 5.1.0 + */ + attachmentClick: EventEmitter>; + /** + * Event emitted when the remove action is clicked. @since 5.1.0 + */ + removeClick: EventEmitter>; +} + + +@ProxyCmp({ + defineCustomElementFn: defineIxChatUserMessage, + inputs: ['message'] +}) +@Component({ + selector: 'ix-chat-user-message', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: ['message'], +}) +export class IxChatUserMessage { + protected el: HTMLIxChatUserMessageElement; + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + } +} + + +export declare interface IxChatUserMessage extends Components.IxChatUserMessage {} + + @ProxyCmp({ defineCustomElementFn: defineIxCheckbox, inputs: ['checked', 'disabled', 'indeterminate', 'label', 'name', 'required', 'value'] diff --git a/packages/core/scss/mixins/_scrollbar.scss b/packages/core/scss/mixins/_scrollbar.scss index 3467193c259..aa48545913e 100644 --- a/packages/core/scss/mixins/_scrollbar.scss +++ b/packages/core/scss/mixins/_scrollbar.scss @@ -6,13 +6,13 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ -@mixin scrollbar() { - ::-webkit-scrollbar-button { +@mixin _scrollbar-rules { + &::-webkit-scrollbar-button { display: none; } @-moz-document url-prefix() { - * { + & { scrollbar-color: var(--theme-scrollbar-thumb--background) var(--theme-scrollbar-track--background); scrollbar-width: thin; @@ -20,33 +20,45 @@ } /* width */ - ::-webkit-scrollbar { + &::-webkit-scrollbar { width: 0.5rem; height: 0.5rem; } /* Track */ - ::-webkit-scrollbar-track { + &::-webkit-scrollbar-track { border-radius: 5px; background: var(--theme-scrollbar-track--background); } - ::-webkit-scrollbar-track:hover { + &::-webkit-scrollbar-track:hover { background: var(--theme-scrollbar-track--background--hover); } /* Handle */ - ::-webkit-scrollbar-thumb { + &::-webkit-scrollbar-thumb { border-radius: 5px; background: var(--theme-scrollbar-thumb--background); } /* Handle on hover */ - ::-webkit-scrollbar-thumb:hover { + &::-webkit-scrollbar-thumb:hover { background: var(--theme-scrollbar-thumb--background--hover); } - ::-webkit-scrollbar-corner { + &::-webkit-scrollbar-corner { display: none; } } + +@mixin scrollbar($include-current: false) { + * { + @include _scrollbar-rules; + } + + @if $include-current { + & { + @include _scrollbar-rules; + } + } +} diff --git a/packages/core/src/components.d.ts b/packages/core/src/components.d.ts index b8e6da5b3b5..8078ab6fe43 100644 --- a/packages/core/src/components.d.ts +++ b/packages/core/src/components.d.ts @@ -18,6 +18,7 @@ import { CardAccordionExpandChangeEvent, CardAccordionVariant } from "./componen import { FilterState } from "./components/category-filter/filter-state"; import { LogicalFilterOperator } from "./components/category-filter/logical-filter-operator"; import { InputState } from "./components/category-filter/input-state"; +import { ChatPromptAttachmentStatus } from "./components/chat-prompt-attachment/chat-prompt-attachment.types"; import { ChipVariant } from "./components/chip/chip.types"; import { ColumnSize } from "./components/col/col.types"; import { ContentHeaderVariant } from "./components/content-header/content-header.types"; @@ -73,6 +74,7 @@ export { CardAccordionExpandChangeEvent, CardAccordionVariant } from "./componen export { FilterState } from "./components/category-filter/filter-state"; export { LogicalFilterOperator } from "./components/category-filter/logical-filter-operator"; export { InputState } from "./components/category-filter/input-state"; +export { ChatPromptAttachmentStatus } from "./components/chat-prompt-attachment/chat-prompt-attachment.types"; export { ChipVariant } from "./components/chip/chip.types"; export { ColumnSize } from "./components/col/col.types"; export { ContentHeaderVariant } from "./components/content-header/content-header.types"; @@ -624,6 +626,177 @@ export namespace Components { */ "uniqueCategories": boolean; } + /** + * @since 5.1.0 + */ + interface IxChat { + } + /** + * @since 5.1.0 + */ + interface IxChatAiMessage { + } + /** + * @since 5.1.0 + * @form-ready + */ + interface IxChatInput { + /** + * Character limit used for the optional inline character limit message. Falls back to `maxLength` when not set. + * @since 5.1.0 + */ + "characterLimit"?: number; + /** + * Percentage of the character limit that triggers the soft warning. Define a number between 0 and 1 (e.g. 0.8 for 80%). + * @since 5.1.0 + * @default 0.9 + */ + "characterLimitWarningThreshold": number; + /** + * Specifies whether the chat input is disabled. + * @since 5.1.0 + * @default false + */ + "disabled": boolean; + /** + * Disclaimer text displayed below the chat input. + * @since 5.1.0 + * @default 'This content is AI-generated. Always verify the information for accuracy.' + */ + "disclaimer": string; + /** + * Focuses the chat input. + * @since 5.1.0 + */ + "focusInput": () => Promise; + "getAssociatedFormElement": () => Promise; + /** + * Returns the native textarea element used by the chat input. + * @since 5.1.0 + */ + "getNativeInputElement": () => Promise; + /** + * i18n label for the hard character limit message. Use `{current}` and `{limit}` placeholders to place the values in any order. + * @since 5.1.0 + * @default 'Character limit reached ({current} / {limit} characters)' + */ + "i18nCharacterLimitReached": string; + /** + * i18n label for the soft character limit warning. Use `{current}` and `{limit}` placeholders to place the values in any order. + * @since 5.1.0 + * @default "You're nearing the limit ({current} / {limit} characters)" + */ + "i18nCharacterLimitWarning": string; + /** + * If true, pressing Enter inserts a line break instead of submitting the prompt. + * @since 5.1.0 + * @default false + */ + "insertLineBreakOnEnter": boolean; + /** + * The maximum length of the chat input. + * @since 5.1.0 + */ + "maxLength"?: number; + /** + * Maximum number of visible text rows before the input becomes scrollable. + * @since 5.1.0 + * @default 6 + */ + "maxRows": number; + /** + * Minimum number of visible text rows. + * @since 5.1.0 + * @default 1 + */ + "minRows": number; + /** + * The name of the chat input. + * @since 5.1.0 + */ + "name"?: string; + /** + * The placeholder text for the chat input. + * @since 5.1.0 + * @default 'Enter a command, question or topic...' + */ + "placeholder": string; + /** + * Specifies whether the chat input is readonly. + * @since 5.1.0 + * @default false + */ + "readonly": boolean; + /** + * The state of the chat input, which can be either 'input' or 'processing'. + * @since 5.1.0 + * @default 'input' + */ + "state"?: 'input' | 'processing'; + /** + * Accessible label for the native textarea. + * @since 5.1.0 + * @default 'Chat input' + */ + "textareaLabel": string; + /** + * The value of the chat input. + * @since 5.1.0 + * @default '' + */ + "value": string; + } + /** + * @since 5.1.0 + */ + interface IxChatPromptAttachment { + /** + * Name of the attached file. + * @since 5.1.0 + * @default '' + */ + "fileName": string; + /** + * Hide the remove action. + * @since 5.1.0 + * @default false + */ + "hideRemoveButton": boolean; + /** + * Icon displayed before the file name. + * @since 5.1.0 + * @default iconTxtDocument + */ + "icon": string; + /** + * Enable preview interaction for default attachments. + * @since 5.1.0 + * @default false + */ + "previewSupported": boolean; + /** + * Accessible label for the remove action. + * @since 5.1.0 + * @default 'Remove attachment' + */ + "removeAriaLabel": string; + /** + * Upload status of the attachment. + * @since 5.1.0 + * @default 'default' + */ + "status": ChatPromptAttachmentStatus; + } + /** + * @since 5.1.0 + */ + interface IxChatUserMessage { + /** + * Text displayed in the user message bubble. + * @since 5.1.0 + */ + "message"?: string; + } /** * @form-ready */ @@ -745,7 +918,7 @@ export namespace Components { */ "icon"?: string; /** - * Determines if the chip is interactive. If true no user input (e.g. mouse states, keyboard navigation) will be possible and also the close button will not be present. + * Determines if the chip is interactive. If true no user input (e.g. mouse states, keyboard navigation) will be possible on the main chip content. * @default false */ "inactive": boolean; @@ -4595,6 +4768,14 @@ export interface IxCategoryFilterCustomEvent extends CustomEvent { detail: T; target: HTMLIxCategoryFilterElement; } +export interface IxChatInputCustomEvent extends CustomEvent { + detail: T; + target: HTMLIxChatInputElement; +} +export interface IxChatPromptAttachmentCustomEvent extends CustomEvent { + detail: T; + target: HTMLIxChatPromptAttachmentElement; +} export interface IxCheckboxCustomEvent extends CustomEvent { detail: T; target: HTMLIxCheckboxElement; @@ -4993,6 +5174,78 @@ declare global { prototype: HTMLIxCategoryFilterElement; new (): HTMLIxCategoryFilterElement; }; + /** + * @since 5.1.0 + */ + interface HTMLIxChatElement extends Components.IxChat, HTMLStencilElement { + } + var HTMLIxChatElement: { + prototype: HTMLIxChatElement; + new (): HTMLIxChatElement; + }; + /** + * @since 5.1.0 + */ + interface HTMLIxChatAiMessageElement extends Components.IxChatAiMessage, HTMLStencilElement { + } + var HTMLIxChatAiMessageElement: { + prototype: HTMLIxChatAiMessageElement; + new (): HTMLIxChatAiMessageElement; + }; + interface HTMLIxChatInputElementEventMap { + "valueChange": string; + "ixBlur": void; + "ixChange": string; + "promptSubmit": string; + } + /** + * @since 5.1.0 + * @form-ready + */ + interface HTMLIxChatInputElement extends Components.IxChatInput, HTMLStencilElement { + addEventListener(type: K, listener: (this: HTMLIxChatInputElement, ev: IxChatInputCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLIxChatInputElement, ev: IxChatInputCustomEvent) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void; + } + var HTMLIxChatInputElement: { + prototype: HTMLIxChatInputElement; + new (): HTMLIxChatInputElement; + }; + interface HTMLIxChatPromptAttachmentElementEventMap { + "attachmentClick": void; + "removeClick": void; + } + /** + * @since 5.1.0 + */ + interface HTMLIxChatPromptAttachmentElement extends Components.IxChatPromptAttachment, HTMLStencilElement { + addEventListener(type: K, listener: (this: HTMLIxChatPromptAttachmentElement, ev: IxChatPromptAttachmentCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLIxChatPromptAttachmentElement, ev: IxChatPromptAttachmentCustomEvent) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void; + } + var HTMLIxChatPromptAttachmentElement: { + prototype: HTMLIxChatPromptAttachmentElement; + new (): HTMLIxChatPromptAttachmentElement; + }; + /** + * @since 5.1.0 + */ + interface HTMLIxChatUserMessageElement extends Components.IxChatUserMessage, HTMLStencilElement { + } + var HTMLIxChatUserMessageElement: { + prototype: HTMLIxChatUserMessageElement; + new (): HTMLIxChatUserMessageElement; + }; interface HTMLIxCheckboxElementEventMap { "checkedChange": boolean; "valueChange": string; @@ -6292,6 +6545,11 @@ declare global { "ix-card-list": HTMLIxCardListElement; "ix-card-title": HTMLIxCardTitleElement; "ix-category-filter": HTMLIxCategoryFilterElement; + "ix-chat": HTMLIxChatElement; + "ix-chat-ai-message": HTMLIxChatAiMessageElement; + "ix-chat-input": HTMLIxChatInputElement; + "ix-chat-prompt-attachment": HTMLIxChatPromptAttachmentElement; + "ix-chat-user-message": HTMLIxChatUserMessageElement; "ix-checkbox": HTMLIxCheckboxElement; "ix-checkbox-group": HTMLIxCheckboxGroupElement; "ix-chip": HTMLIxChipElement; @@ -6960,6 +7218,200 @@ declare namespace LocalJSX { */ "uniqueCategories"?: boolean; } + /** + * @since 5.1.0 + */ + interface IxChat { + } + /** + * @since 5.1.0 + */ + interface IxChatAiMessage { + } + /** + * @since 5.1.0 + * @form-ready + */ + interface IxChatInput { + /** + * Character limit used for the optional inline character limit message. Falls back to `maxLength` when not set. + * @since 5.1.0 + */ + "characterLimit"?: number; + /** + * Percentage of the character limit that triggers the soft warning. Define a number between 0 and 1 (e.g. 0.8 for 80%). + * @since 5.1.0 + * @default 0.9 + */ + "characterLimitWarningThreshold"?: number; + /** + * Specifies whether the chat input is disabled. + * @since 5.1.0 + * @default false + */ + "disabled"?: boolean; + /** + * Disclaimer text displayed below the chat input. + * @since 5.1.0 + * @default 'This content is AI-generated. Always verify the information for accuracy.' + */ + "disclaimer"?: string; + /** + * The `id` of a `
` element to associate this element with. + */ + "form"?: string; + /** + * i18n label for the hard character limit message. Use `{current}` and `{limit}` placeholders to place the values in any order. + * @since 5.1.0 + * @default 'Character limit reached ({current} / {limit} characters)' + */ + "i18nCharacterLimitReached"?: string; + /** + * i18n label for the soft character limit warning. Use `{current}` and `{limit}` placeholders to place the values in any order. + * @since 5.1.0 + * @default "You're nearing the limit ({current} / {limit} characters)" + */ + "i18nCharacterLimitWarning"?: string; + /** + * If true, pressing Enter inserts a line break instead of submitting the prompt. + * @since 5.1.0 + * @default false + */ + "insertLineBreakOnEnter"?: boolean; + /** + * The maximum length of the chat input. + * @since 5.1.0 + */ + "maxLength"?: number; + /** + * Maximum number of visible text rows before the input becomes scrollable. + * @since 5.1.0 + * @default 6 + */ + "maxRows"?: number; + /** + * Minimum number of visible text rows. + * @since 5.1.0 + * @default 1 + */ + "minRows"?: number; + /** + * The name of the chat input. + * @since 5.1.0 + */ + "name"?: string; + /** + * Event emitted when the chat input loses focus. + * @since 5.1.0 + */ + "onIxBlur"?: (event: IxChatInputCustomEvent) => void; + /** + * Event emitted when the chat input loses focus and the value has changed. + * @since 5.1.0 + */ + "onIxChange"?: (event: IxChatInputCustomEvent) => void; + /** + * Event emitted when the prompt is submitted by the send button or Enter key. + * @since 5.1.0 + */ + "onPromptSubmit"?: (event: IxChatInputCustomEvent) => void; + /** + * Event emitted when the value of the chat input changes. + * @since 5.1.0 + */ + "onValueChange"?: (event: IxChatInputCustomEvent) => void; + /** + * The placeholder text for the chat input. + * @since 5.1.0 + * @default 'Enter a command, question or topic...' + */ + "placeholder"?: string; + /** + * Specifies whether the chat input is readonly. + * @since 5.1.0 + * @default false + */ + "readonly"?: boolean; + /** + * The state of the chat input, which can be either 'input' or 'processing'. + * @since 5.1.0 + * @default 'input' + */ + "state"?: 'input' | 'processing'; + /** + * Accessible label for the native textarea. + * @since 5.1.0 + * @default 'Chat input' + */ + "textareaLabel"?: string; + /** + * The value of the chat input. + * @since 5.1.0 + * @default '' + */ + "value"?: string; + } + /** + * @since 5.1.0 + */ + interface IxChatPromptAttachment { + /** + * Name of the attached file. + * @since 5.1.0 + * @default '' + */ + "fileName"?: string; + /** + * Hide the remove action. + * @since 5.1.0 + * @default false + */ + "hideRemoveButton"?: boolean; + /** + * Icon displayed before the file name. + * @since 5.1.0 + * @default iconTxtDocument + */ + "icon"?: string; + /** + * Event emitted when the attachment is clicked. + * @since 5.1.0 + */ + "onAttachmentClick"?: (event: IxChatPromptAttachmentCustomEvent) => void; + /** + * Event emitted when the remove action is clicked. + * @since 5.1.0 + */ + "onRemoveClick"?: (event: IxChatPromptAttachmentCustomEvent) => void; + /** + * Enable preview interaction for default attachments. + * @since 5.1.0 + * @default false + */ + "previewSupported"?: boolean; + /** + * Accessible label for the remove action. + * @since 5.1.0 + * @default 'Remove attachment' + */ + "removeAriaLabel"?: string; + /** + * Upload status of the attachment. + * @since 5.1.0 + * @default 'default' + */ + "status"?: ChatPromptAttachmentStatus; + } + /** + * @since 5.1.0 + */ + interface IxChatUserMessage { + /** + * Text displayed in the user message bubble. + * @since 5.1.0 + */ + "message"?: string; + } /** * @form-ready */ @@ -7092,7 +7544,7 @@ declare namespace LocalJSX { */ "icon"?: string; /** - * Determines if the chip is interactive. If true no user input (e.g. mouse states, keyboard navigation) will be possible and also the close button will not be present. + * Determines if the chip is interactive. If true no user input (e.g. mouse states, keyboard navigation) will be possible on the main chip content. * @default false */ "inactive"?: boolean; @@ -11275,6 +11727,35 @@ declare namespace LocalJSX { "ariaLabelFilterInput": string; "enableTopLayer": boolean; } + interface IxChatInputAttributes { + "state": 'input' | 'processing'; + "name": string; + "placeholder": string; + "value": string; + "disabled": boolean; + "readonly": boolean; + "textareaLabel": string; + "maxLength": number; + "characterLimit": number; + "i18nCharacterLimitReached": string; + "i18nCharacterLimitWarning": string; + "characterLimitWarningThreshold": number; + "minRows": number; + "maxRows": number; + "insertLineBreakOnEnter": boolean; + "disclaimer": string; + } + interface IxChatPromptAttachmentAttributes { + "fileName": string; + "status": ChatPromptAttachmentStatus; + "icon": string; + "hideRemoveButton": boolean; + "previewSupported": boolean; + "removeAriaLabel": string; + } + interface IxChatUserMessageAttributes { + "message": string; + } interface IxCheckboxAttributes { "name": string; "value": string; @@ -12195,6 +12676,11 @@ declare namespace LocalJSX { "ix-card-list": Omit & { [K in keyof IxCardList & keyof IxCardListAttributes]?: IxCardList[K] } & { [K in keyof IxCardList & keyof IxCardListAttributes as `attr:${K}`]?: IxCardListAttributes[K] } & { [K in keyof IxCardList & keyof IxCardListAttributes as `prop:${K}`]?: IxCardList[K] }; "ix-card-title": IxCardTitle; "ix-category-filter": Omit & { [K in keyof IxCategoryFilter & keyof IxCategoryFilterAttributes]?: IxCategoryFilter[K] } & { [K in keyof IxCategoryFilter & keyof IxCategoryFilterAttributes as `attr:${K}`]?: IxCategoryFilterAttributes[K] } & { [K in keyof IxCategoryFilter & keyof IxCategoryFilterAttributes as `prop:${K}`]?: IxCategoryFilter[K] }; + "ix-chat": IxChat; + "ix-chat-ai-message": IxChatAiMessage; + "ix-chat-input": Omit & { [K in keyof IxChatInput & keyof IxChatInputAttributes]?: IxChatInput[K] } & { [K in keyof IxChatInput & keyof IxChatInputAttributes as `attr:${K}`]?: IxChatInputAttributes[K] } & { [K in keyof IxChatInput & keyof IxChatInputAttributes as `prop:${K}`]?: IxChatInput[K] }; + "ix-chat-prompt-attachment": Omit & { [K in keyof IxChatPromptAttachment & keyof IxChatPromptAttachmentAttributes]?: IxChatPromptAttachment[K] } & { [K in keyof IxChatPromptAttachment & keyof IxChatPromptAttachmentAttributes as `attr:${K}`]?: IxChatPromptAttachmentAttributes[K] } & { [K in keyof IxChatPromptAttachment & keyof IxChatPromptAttachmentAttributes as `prop:${K}`]?: IxChatPromptAttachment[K] }; + "ix-chat-user-message": Omit & { [K in keyof IxChatUserMessage & keyof IxChatUserMessageAttributes]?: IxChatUserMessage[K] } & { [K in keyof IxChatUserMessage & keyof IxChatUserMessageAttributes as `attr:${K}`]?: IxChatUserMessageAttributes[K] } & { [K in keyof IxChatUserMessage & keyof IxChatUserMessageAttributes as `prop:${K}`]?: IxChatUserMessage[K] }; "ix-checkbox": Omit & { [K in keyof IxCheckbox & keyof IxCheckboxAttributes]?: IxCheckbox[K] } & { [K in keyof IxCheckbox & keyof IxCheckboxAttributes as `attr:${K}`]?: IxCheckboxAttributes[K] } & { [K in keyof IxCheckbox & keyof IxCheckboxAttributes as `prop:${K}`]?: IxCheckbox[K] }; "ix-checkbox-group": Omit & { [K in keyof IxCheckboxGroup & keyof IxCheckboxGroupAttributes]?: IxCheckboxGroup[K] } & { [K in keyof IxCheckboxGroup & keyof IxCheckboxGroupAttributes as `attr:${K}`]?: IxCheckboxGroupAttributes[K] } & { [K in keyof IxCheckboxGroup & keyof IxCheckboxGroupAttributes as `prop:${K}`]?: IxCheckboxGroup[K] }; "ix-chip": Omit & { [K in keyof IxChip & keyof IxChipAttributes]?: IxChip[K] } & { [K in keyof IxChip & keyof IxChipAttributes as `attr:${K}`]?: IxChipAttributes[K] } & { [K in keyof IxChip & keyof IxChipAttributes as `prop:${K}`]?: IxChip[K] }; @@ -12311,6 +12797,27 @@ declare module "@stencil/core" { "ix-card-list": LocalJSX.IntrinsicElements["ix-card-list"] & JSXBase.HTMLAttributes; "ix-card-title": LocalJSX.IntrinsicElements["ix-card-title"] & JSXBase.HTMLAttributes; "ix-category-filter": LocalJSX.IntrinsicElements["ix-category-filter"] & JSXBase.HTMLAttributes; + /** + * @since 5.1.0 + */ + "ix-chat": LocalJSX.IntrinsicElements["ix-chat"] & JSXBase.HTMLAttributes; + /** + * @since 5.1.0 + */ + "ix-chat-ai-message": LocalJSX.IntrinsicElements["ix-chat-ai-message"] & JSXBase.HTMLAttributes; + /** + * @since 5.1.0 + * @form-ready + */ + "ix-chat-input": LocalJSX.IntrinsicElements["ix-chat-input"] & JSXBase.HTMLAttributes; + /** + * @since 5.1.0 + */ + "ix-chat-prompt-attachment": LocalJSX.IntrinsicElements["ix-chat-prompt-attachment"] & JSXBase.HTMLAttributes; + /** + * @since 5.1.0 + */ + "ix-chat-user-message": LocalJSX.IntrinsicElements["ix-chat-user-message"] & JSXBase.HTMLAttributes; /** * @form-ready */ diff --git a/packages/core/src/components/chat-ai-message/chat-ai-message.scss b/packages/core/src/components/chat-ai-message/chat-ai-message.scss new file mode 100644 index 00000000000..c6c3f2a7913 --- /dev/null +++ b/packages/core/src/components/chat-ai-message/chat-ai-message.scss @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +@use 'mixins/shadow-dom/component'; + +:host { + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + min-width: 0; + gap: var(--theme-space-3); + box-sizing: border-box; + color: var(--theme-color-std-text); + + @include component.ix-component; + + .message { + display: flex; + flex-direction: column; + align-items: stretch; + width: 100%; + min-width: 0; + gap: var(--theme-space-3); + font-size: var(--theme-font-size-l); + line-height: 1.5; + overflow-wrap: anywhere; + } + + .message ::slotted(*) { + margin: 0; + color: inherit; + overflow-wrap: anywhere; + } + + .message ::slotted(h1), + .message ::slotted(h2), + .message ::slotted(h3), + .message ::slotted(h4), + .message ::slotted(h5), + .message ::slotted(h6) { + font-size: var(--theme-font-size-l); + font-weight: var(--theme-font-weight-bold); + line-height: 1.2; + } + + .meta { + display: none; + align-items: center; + gap: var(--theme-space-3); + width: 100%; + min-height: 2rem; + } + + .actions { + display: none; + align-items: center; + gap: 0.25rem; + } + + .sources { + display: none; + align-items: center; + } + + .actions ::slotted(ix-icon-button), + .sources ::slotted(*) { + flex: 0 0 auto; + } +} + +:host(.has-meta) { + .meta { + display: flex; + } +} + +:host(.has-actions) { + .actions { + display: flex; + } +} + +:host(.has-sources) { + .sources { + display: flex; + } +} diff --git a/packages/core/src/components/chat-ai-message/chat-ai-message.tsx b/packages/core/src/components/chat-ai-message/chat-ai-message.tsx new file mode 100644 index 00000000000..9a0c6c5df15 --- /dev/null +++ b/packages/core/src/components/chat-ai-message/chat-ai-message.tsx @@ -0,0 +1,72 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Component, Host, State, h } from '@stencil/core'; + +/** + * @since 5.1.0 + * @slot - AI-generated message content + * @slot actions - Optional actions displayed below the AI message + * @slot sources - Optional sources displayed next to the AI message actions + */ +@Component({ + tag: 'ix-chat-ai-message', + styleUrl: 'chat-ai-message.scss', + shadow: true, +}) +export class ChatAiMessage { + @State() hasActions = false; + @State() hasSources = false; + + private hasAssignedContent(slot: HTMLSlotElement) { + return slot.assignedNodes({ flatten: true }).some((node) => { + return node.nodeType === 1 || !!node.textContent?.trim(); + }); + } + + private handleActionsSlotChange(event: Event) { + this.hasActions = this.hasAssignedContent(event.target as HTMLSlotElement); + } + + private handleSourcesSlotChange(event: Event) { + this.hasSources = this.hasAssignedContent(event.target as HTMLSlotElement); + } + + render() { + const hasMeta = this.hasActions || this.hasSources; + + return ( + +
+ +
+
+
+ this.handleActionsSlotChange(event)} + > +
+
+ this.handleSourcesSlotChange(event)} + > +
+
+
+ ); + } +} diff --git a/packages/core/src/components/chat-ai-message/tests/chat-ai-message.ct.ts b/packages/core/src/components/chat-ai-message/tests/chat-ai-message.ct.ts new file mode 100644 index 00000000000..97e960dea00 --- /dev/null +++ b/packages/core/src/components/chat-ai-message/tests/chat-ai-message.ct.ts @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import { expect } from '@playwright/test'; +import { regressionTest } from '@utils/test'; + +regressionTest( + 'ix-chat-ai-message renders slotted message content', + async ({ mount, page }) => { + await mount(` + +

Text block example

+

Effective asset performance management improves uptime.

+
+ `); + + const message = page.locator('ix-chat-ai-message'); + + await expect(message.locator('h3')).toContainText('Text block example'); + await expect(message.locator('p')).toContainText( + 'Effective asset performance management improves uptime.' + ); + await expect(message.locator('.meta')).not.toBeVisible(); + } +); + +regressionTest( + 'ix-chat-ai-message renders optional actions', + async ({ mount, page }) => { + await mount(` + +

Analyze alarms

+ + +
+ `); + + const message = page.locator('ix-chat-ai-message'); + + await expect(message).toHaveClass(/has-actions/); + await expect(message.locator('.meta')).toBeVisible(); + await expect(message.locator('.actions')).toBeVisible(); + await expect(page.locator('button[slot="actions"]')).toHaveCount(2); + } +); + +regressionTest( + 'ix-chat-ai-message renders optional sources', + async ({ mount, page }) => { + await mount(` + +

Analyze alarms

+ Sources +
+ `); + + const message = page.locator('ix-chat-ai-message'); + + await expect(message).toHaveClass(/has-sources/); + await expect(message.locator('.meta')).toBeVisible(); + await expect(message.locator('.sources')).toBeVisible(); + await expect(page.locator('ix-pill[slot="sources"]')).toContainText( + 'Sources' + ); + } +); diff --git a/packages/core/src/components/chat-input/chat-input.scss b/packages/core/src/components/chat-input/chat-input.scss new file mode 100644 index 00000000000..0df12ff0298 --- /dev/null +++ b/packages/core/src/components/chat-input/chat-input.scss @@ -0,0 +1,209 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +@use 'mixins/shadow-dom/component'; +@use 'mixins/scrollbar' as scrollbar; + +:host { + display: block; + width: 100%; + + @include component.ix-component; + + .chat-input { + display: flex; + flex-direction: column; + width: 100%; + min-height: 8rem; + padding: var(--theme-space-2); + box-sizing: border-box; + background-color: var(--theme-input--background); + border: var(--theme-input--border-thickness) solid + var(--theme-input--border-color); + border-radius: var(--theme-input--border-radius); + box-shadow: var(--theme-input--box-shadow); + color: var(--theme-input--color); + } + + .follow-up-prompts { + display: none; + flex-wrap: wrap; + align-items: flex-start; + gap: var(--theme-space-1); + width: 100%; + margin-bottom: var(--theme-space-1); + } + + .follow-up-prompts ::slotted(*) { + flex: 0 1 auto; + } + + .follow-up-prompts ::slotted(ix-button) { + max-width: 25rem; + min-width: 5rem; + } + + .follow-up-prompts ::slotted(ix-icon-button) { + flex: 0 0 auto; + } + + .chat-input:hover { + border-color: var(--theme-input--border-color--hover); + background-color: var(--theme-input--background--hover); + } + + .chat-input:focus-within { + outline: 1px solid var(--theme-color-focus-bdr); + outline-offset: var(--theme-input--focus--outline-offset); + border-color: var(--theme-input--border-color--focus); + } + + .attachments { + display: none; + box-sizing: border-box; + width: 100%; + max-width: 100%; + gap: var(--theme-space-2); + margin-bottom: var(--theme-space-2); + } + + .attachments.has-attachments { + display: flex; + } + + .attachments--wrap { + flex-wrap: wrap; + max-height: calc(4rem + var(--theme-space-2)); + overflow: hidden; + } + + .attachments--scroll { + flex-wrap: nowrap; + overflow-x: auto; + overflow-y: hidden; + + @include scrollbar.scrollbar($include-current: true); + } + + .attachments.has-attachment-scrollbar { + padding-bottom: 0.25rem; + } + + .attachments ::slotted(ix-chat-prompt-attachment) { + flex: 0 1 auto; + } + + .attachments--scroll ::slotted(ix-chat-prompt-attachment) { + flex: 0 0 auto; + } + + textarea { + flex: 1 1 auto; + width: 100%; + min-height: 2rem; + padding: 0; + border: 0; + outline: 0; + box-sizing: border-box; + resize: none; + background: transparent; + color: inherit; + font: inherit; + } + + textarea::placeholder { + color: var(--theme-input-hint--color); + } + + .character-limit { + display: flex; + align-items: center; + gap: var(--theme-space-1); + width: 100%; + margin-top: var(--theme-space-2); + } + + .character-limit ix-icon { + flex: 0 0 auto; + } + + .character-limit--soft ix-icon { + color: var(--theme-color-info); + } + + .character-limit--hard ix-icon { + color: var(--theme-color-warning); + } + + .action-row { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + min-height: 2rem; + margin-top: var(--theme-space-1); + } + + .left-actions, + .right-actions { + display: flex; + align-items: center; + gap: var(--theme-space-1); + } + + .left-actions { + flex: 1 1 auto; + min-width: 0; + } + + .right-actions { + flex: 0 0 auto; + justify-content: flex-end; + } + + .disclaimer { + display: block; + width: 100%; + margin-top: var(--theme-space-1); + text-align: center; + } +} + +:host(.has-follow-up) .follow-up-prompts { + display: flex; +} + +:host(.disabled) { + pointer-events: none; + + .chat-input { + border-color: var(--theme-input--border-color--disabled); + box-shadow: none; + color: var(--theme-input--color--disabled); + background-color: transparent; + } + + textarea::placeholder { + color: transparent; + } +} + +:host(.readonly) { + .chat-input { + border-color: var(--theme-input--border-color--readonly); + box-shadow: none; + background-color: transparent; + } + + .chat-input:hover, + .chat-input:focus-within { + border-color: var(--theme-input--border-color--readonly); + outline: none; + } +} diff --git a/packages/core/src/components/chat-input/chat-input.tsx b/packages/core/src/components/chat-input/chat-input.tsx new file mode 100644 index 00000000000..b329c29bdeb --- /dev/null +++ b/packages/core/src/components/chat-input/chat-input.tsx @@ -0,0 +1,620 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { + iconCircleStop, + iconInfo, + iconSendRightFilled, + iconWarning, +} from '@siemens/ix-icons/icons'; +import { + AttachInternals, + Component, + Element, + Event, + EventEmitter, + Host, + Method, + Mixin, + Prop, + State, + Watch, + h, +} from '@stencil/core'; +import { DefaultMixins } from '../utils/internal/component'; +import { ComponentIdMixin } from '../utils/internal/mixins/id.mixin'; +import { makeRef } from '../utils/make-ref'; +import { createMutationObserver } from '../utils/mutation-observer'; +import { requestAnimationFrameNoNgZone } from '../utils/requestAnimationFrame'; + +/** + * @since 5.1.0 + * @form-ready + * @slot follow-up - Optional refresh action and follow-up prompt buttons displayed above the chat input + * @slot attachments - Attachments displayed above the prompt text area + * @slot start - Element will be displayed in the left action area + * @slot end - Element will be displayed in the right action area before the submit button + */ +@Component({ + tag: 'ix-chat-input', + styleUrl: 'chat-input.scss', + shadow: true, + formAssociated: true, +}) +export class ChatInput extends Mixin(...DefaultMixins, ComponentIdMixin) { + @Element() override hostElement!: HTMLIxChatInputElement; + @AttachInternals() formInternals!: ElementInternals; + + /** + * The state of the chat input, which can be either 'input' or 'processing'. + * @since 5.1.0 + */ + @Prop() state?: 'input' | 'processing' = 'input'; + + /** + * The name of the chat input. + * @since 5.1.0 + */ + @Prop({ reflect: true }) name?: string; + + /** + * The placeholder text for the chat input. + * @since 5.1.0 + */ + @Prop({ reflect: true }) placeholder = + 'Enter a command, question or topic...'; + + /** + * The value of the chat input. + * @since 5.1.0 + */ + @Prop({ reflect: true, mutable: true }) value: string = ''; + + /** + * Specifies whether the chat input is disabled. + * @since 5.1.0 + */ + @Prop({ reflect: true }) disabled: boolean = false; + + /** + * Specifies whether the chat input is readonly. + * @since 5.1.0 + */ + @Prop({ reflect: true }) readonly: boolean = false; + + /** + * Accessible label for the native textarea. + * @since 5.1.0 + */ + @Prop() textareaLabel: string = 'Chat input'; + + /** + * The maximum length of the chat input. + * @since 5.1.0 + */ + @Prop() maxLength?: number; + + /** + * Character limit used for the optional inline character limit message. + * Falls back to `maxLength` when not set. + * @since 5.1.0 + */ + @Prop() characterLimit?: number; + + /** + * i18n label for the hard character limit message. + * Use `{current}` and `{limit}` placeholders to place the values in any order. + * @since 5.1.0 + */ + // eslint-disable-next-line @stencil-community/decorators-style + @Prop({ attribute: 'i18n-character-limit-reached' }) + i18nCharacterLimitReached = + 'Character limit reached ({current} / {limit} characters)'; + + /** + * i18n label for the soft character limit warning. + * Use `{current}` and `{limit}` placeholders to place the values in any order. + * @since 5.1.0 + */ + // eslint-disable-next-line @stencil-community/decorators-style + @Prop({ attribute: 'i18n-character-limit-warning' }) + i18nCharacterLimitWarning = + "You're nearing the limit ({current} / {limit} characters)"; + + /** + * Percentage of the character limit that triggers the soft warning. Define a number between 0 and 1 (e.g. 0.8 for 80%). + * @since 5.1.0 + */ + @Prop() characterLimitWarningThreshold: number = 0.9; + + /** + * Minimum number of visible text rows. + * @since 5.1.0 + */ + @Prop() minRows: number = 1; + + /** + * Maximum number of visible text rows before the input becomes scrollable. + * @since 5.1.0 + */ + @Prop() maxRows: number = 6; + + /** + * If true, pressing Enter inserts a line break instead of submitting the prompt. + * @since 5.1.0 + */ + @Prop() insertLineBreakOnEnter: boolean = false; + + /** + * Disclaimer text displayed below the chat input. + * @since 5.1.0 + */ + @Prop() disclaimer = + 'This content is AI-generated. Always verify the information for accuracy.'; + + /** + * Event emitted when the value of the chat input changes. + * @since 5.1.0 + */ + @Event() valueChange!: EventEmitter; + + /** + * Event emitted when the chat input loses focus. + * @since 5.1.0 + */ + @Event() ixBlur!: EventEmitter; + + /** + * Event emitted when the chat input loses focus and the value has changed. + * @since 5.1.0 + */ + @Event() ixChange!: EventEmitter; + + /** + * Event emitted when the prompt is submitted by the send button or Enter key. + * @since 5.1.0 + */ + @Event() promptSubmit!: EventEmitter; + + /** @internal */ + public initialValue?: string; + + @State() hasAttachments = false; + @State() hasAttachmentScrollbar = false; + @State() hasFollowUp = false; + + private readonly attachmentsRef = makeRef((attachments) => { + this.initAttachmentResizeObserver(attachments); + this.scheduleAttachmentScrollbarUpdate(); + }); + private readonly textareaRef = makeRef((textarea) => { + this.updateTextareaHeight(textarea); + }); + private attachmentResizeObserver?: ResizeObserver; + private attachmentMutationObserver?: MutationObserver; + private isAttachmentScrollbarUpdateQueued = false; + + override componentWillLoad() { + super.componentWillLoad!(); + this.updateFormInternalValue(this.value); + this.initialValue = this.value; + this.updateHasFollowUp(); + } + + override componentDidLoad() { + super.componentDidLoad!(); + this.updateHasFollowUp(); + this.initAttachmentMutationObserver(); + this.scheduleAttachmentScrollbarUpdate(); + this.updateTextareaHeight(); + } + + override componentDidRender() { + super.componentDidRender!(); + this.scheduleAttachmentScrollbarUpdate(); + this.updateTextareaHeight(); + } + + override disconnectedCallback() { + super.disconnectedCallback!(); + this.attachmentResizeObserver?.disconnect(); + this.attachmentMutationObserver?.disconnect(); + } + + @Watch('value') + onValueChange(value: string) { + this.formInternals.setFormValue(value); + this.updateTextareaHeight(); + } + + @Watch('minRows') + @Watch('maxRows') + onRowsChange() { + this.updateTextareaHeight(); + } + + updateFormInternalValue(value: string) { + this.formInternals.setFormValue(value); + this.value = value; + } + + /** @internal */ + @Method() + async getAssociatedFormElement(): Promise { + return this.formInternals.form; + } + + /** + * Returns the native textarea element used by the chat input. + * @since 5.1.0 + */ + @Method() + async getNativeInputElement(): Promise { + return this.textareaRef.waitForCurrent(); + } + + /** + * Focuses the chat input. + * @since 5.1.0 + */ + @Method() + async focusInput(): Promise { + return (await this.getNativeInputElement()).focus(); + } + + private canSubmit() { + return !this.disabled && !this.readonly && this.value.trim().length > 0; + } + + private emitIxChangeIfNeeded() { + if (this.initialValue !== this.value) { + this.ixChange.emit(this.value); + this.initialValue = this.value; + } + } + + private submitPrompt() { + if (!this.canSubmit()) { + return; + } + + this.emitIxChangeIfNeeded(); + this.promptSubmit.emit(this.value); + } + + private updateTextareaHeight(textarea = this.textareaRef.current) { + if (!textarea) { + return; + } + + const computedStyle = getComputedStyle(textarea); + const lineHeight = Number.parseFloat(computedStyle.lineHeight) || 24; + const minRows = Math.max(this.minRows, 1); + const maxRows = Math.max(this.maxRows, minRows); + const borderHeight = + textarea.offsetHeight - textarea.clientHeight || + Number.parseFloat(computedStyle.borderTopWidth) + + Number.parseFloat(computedStyle.borderBottomWidth); + const minHeight = lineHeight * minRows + borderHeight; + const maxHeight = lineHeight * maxRows + borderHeight; + + textarea.style.height = 'auto'; + textarea.style.minHeight = `${minHeight}px`; + + const nextHeight = Math.min( + Math.max(textarea.scrollHeight + borderHeight, minHeight), + maxHeight + ); + + textarea.style.height = `${nextHeight}px`; + textarea.style.overflowY = + textarea.scrollHeight + borderHeight > maxHeight ? 'auto' : 'hidden'; + } + + private getCharacterLimit() { + const limit = this.characterLimit ?? this.maxLength; + return typeof limit === 'number' && Number.isFinite(limit) && limit > 0 + ? limit + : undefined; + } + + private getNormalizedCharacterLimitWarningThreshold() { + if (!Number.isFinite(this.characterLimitWarningThreshold)) { + return 0.9; + } + + return Math.min(Math.max(this.characterLimitWarningThreshold, 0), 1); + } + + private isSoftCharacterLimitWarning() { + const limit = this.getCharacterLimit(); + + if (!limit || this.isCharacterLimitReached()) { + return false; + } + + const warningLength = Math.ceil( + limit * this.getNormalizedCharacterLimitWarningThreshold() + ); + + return this.value.length >= warningLength; + } + + private isCharacterLimitReached() { + const limit = this.getCharacterLimit(); + + if (!limit) { + return false; + } + + return this.value.length >= limit; + } + + private getCharacterLimitMessage() { + const limit = this.getCharacterLimit(); + const current = this.value.length; + + if (!limit) { + return undefined; + } + + if (this.isCharacterLimitReached()) { + return this.formatCharacterLimitMessage( + this.i18nCharacterLimitReached, + current, + limit + ); + } + + if (this.isSoftCharacterLimitWarning()) { + return this.formatCharacterLimitMessage( + this.i18nCharacterLimitWarning, + current, + limit + ); + } + + return undefined; + } + + private formatCharacterLimitMessage( + template: string, + current: number, + limit: number + ) { + return template + .split('{current}') + .join(String(current)) + .split('{limit}') + .join(String(limit)); + } + + private getCharacterLimitState(): 'soft' | 'hard' | undefined { + if (this.isCharacterLimitReached()) { + return 'hard'; + } + + if (this.isSoftCharacterLimitWarning()) { + return 'soft'; + } + + return undefined; + } + + private handleKeyDown(event: KeyboardEvent) { + if ( + event.key !== 'Enter' || + event.shiftKey || + event.altKey || + event.ctrlKey || + event.metaKey || + this.insertLineBreakOnEnter || + this.state === 'processing' + ) { + return; + } + + event.preventDefault(); + this.submitPrompt(); + } + + private renderCharacterLimit() { + const message = this.getCharacterLimitMessage(); + const state = this.getCharacterLimitState(); + + if (!message || !state) { + return null; + } + + return ( + + + {message} + + ); + } + + private handleAttachmentsSlotChange(event: Event) { + const slot = event.target as HTMLSlotElement; + this.hasAttachments = + slot.assignedElements({ + flatten: true, + }).length > 0; + this.scheduleAttachmentScrollbarUpdate(); + } + + private handleFollowUpSlotChange(event: Event) { + this.hasFollowUp = this.hasAssignedContent(event.target as HTMLSlotElement); + } + + private hasAssignedContent(slot: HTMLSlotElement) { + return slot.assignedNodes({ flatten: true }).some((node) => { + return node.nodeType === 1 || !!node.textContent?.trim(); + }); + } + + private updateHasFollowUp() { + this.hasFollowUp = + this.hostElement.querySelectorAll('[slot="follow-up"]').length > 0; + } + + private initAttachmentResizeObserver(attachments: HTMLDivElement) { + this.attachmentResizeObserver?.disconnect(); + this.attachmentResizeObserver = new ResizeObserver(() => + this.scheduleAttachmentScrollbarUpdate() + ); + this.attachmentResizeObserver.observe(attachments); + } + + private initAttachmentMutationObserver() { + this.attachmentMutationObserver = createMutationObserver(() => { + this.updateHasFollowUp(); + this.scheduleAttachmentScrollbarUpdate(); + }); + this.attachmentMutationObserver.observe(this.hostElement, { + attributes: true, + childList: true, + subtree: true, + }); + } + + private scheduleAttachmentScrollbarUpdate() { + if (this.isAttachmentScrollbarUpdateQueued) { + return; + } + + this.isAttachmentScrollbarUpdateQueued = true; + requestAnimationFrameNoNgZone(() => { + this.isAttachmentScrollbarUpdateQueued = false; + this.updateAttachmentScrollbar(); + }); + } + + private updateAttachmentScrollbar() { + const attachments = this.attachmentsRef.current; + if (!attachments) { + return; + } + + const computedStyle = getComputedStyle(attachments); + const hasHorizontalScrollbar = + ['auto', 'scroll'].includes(computedStyle.overflowX) && + attachments.scrollWidth - attachments.clientWidth > 1; + const hasVerticalScrollbar = + ['auto', 'scroll'].includes(computedStyle.overflowY) && + attachments.scrollHeight - attachments.clientHeight > 1; + const hasAttachmentScrollbar = + this.hasAttachments && (hasHorizontalScrollbar || hasVerticalScrollbar); + + if (this.hasAttachmentScrollbar !== hasAttachmentScrollbar) { + this.hasAttachmentScrollbar = hasAttachmentScrollbar; + } + } + + override render() { + const isProcessing = this.state === 'processing'; + const disabledSubmitButton = + isProcessing || (!this.canSubmit() && this.state === 'input'); + const submitButtonIcon = isProcessing + ? iconCircleStop + : iconSendRightFilled; + + return ( + + +
+
+ this.handleAttachmentsSlotChange(event)} + > +
+ + {this.renderCharacterLimit()} +
+
+ +
+
+ + this.submitPrompt()} + > +
+
+
+ {this.disclaimer && ( + + {this.disclaimer} + + )} +
+ ); + } +} diff --git a/packages/core/src/components/chat-input/tests/chat-input.ct.ts b/packages/core/src/components/chat-input/tests/chat-input.ct.ts new file mode 100644 index 00000000000..32fa8d908f7 --- /dev/null +++ b/packages/core/src/components/chat-input/tests/chat-input.ct.ts @@ -0,0 +1,396 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import { expect } from '@playwright/test'; +import { iconCircleStop, iconSendRightFilled } from '@siemens/ix-icons/icons'; +import { + getFormValue, + preventFormSubmission, + regressionTest, +} from '@utils/test'; + +declare global { + var __promptSubmitValue: string | undefined; +} + +type ValueChangeTestElement = HTMLElement & { + __valueChange?: string; +}; + +function getSubmitButtonIcon(element: Element) { + return (element as HTMLIxIconButtonElement).icon; +} + +regressionTest('renders', async ({ mount, page }) => { + await mount(''); + await expect(page.locator('ix-chat-input')).toHaveClass(/\bhydrated\b/); +}); + +regressionTest('accessibility', async ({ mount, makeAxeBuilder }) => { + await mount(''); + const accessibilityScanResults = await makeAxeBuilder() + .include('ix-chat-input') + .analyze(); + expect(accessibilityScanResults.violations).toEqual([]); +}); + +regressionTest( + 'ix-chat-input emits valueChange when text is entered', + async ({ mount, page }) => { + await mount(''); + + const chatInput = page.locator('ix-chat-input'); + await chatInput.evaluate((element) => { + const testElement = element as ValueChangeTestElement; + testElement.__valueChange = undefined; + element.addEventListener('valueChange', ((event: CustomEvent) => { + testElement.__valueChange = event.detail; + }) as EventListener); + }); + + await chatInput.locator('textarea').fill('Show pump status'); + + await expect(chatInput).toHaveAttribute('value', 'Show pump status'); + const emittedValue = await chatInput.evaluate( + (element) => (element as ValueChangeTestElement).__valueChange + ); + expect(emittedValue).toBe('Show pump status'); + } +); + +regressionTest( + 'ix-chat-input disables submit button while prompt is empty', + async ({ mount, page }) => { + await mount(''); + + const submitButton = page + .locator('ix-chat-input') + .locator('ix-icon-button.submit-button'); + + await expect(submitButton).toHaveClass(/disabled/); + } +); + +regressionTest( + 'ix-chat-input emits promptSubmit on submit button click', + async ({ mount, page }) => { + await mount(''); + + await page.evaluate(() => { + globalThis.__promptSubmitValue = undefined; + document + .querySelector('ix-chat-input') + ?.addEventListener('promptSubmit', ((event: CustomEvent) => { + globalThis.__promptSubmitValue = event.detail; + }) as EventListener); + }); + + await page + .locator('ix-chat-input') + .locator('ix-icon-button.submit-button') + .click(); + + const submittedValue = await page.evaluate( + () => globalThis.__promptSubmitValue + ); + expect(submittedValue).toBe('Analyze alarms'); + } +); + +regressionTest( + 'ix-chat-input emits promptSubmit on Enter but not Shift Enter', + async ({ mount, page }) => { + await mount(''); + + await page.evaluate(() => { + globalThis.__promptSubmitValue = undefined; + document + .querySelector('ix-chat-input') + ?.addEventListener('promptSubmit', ((event: CustomEvent) => { + globalThis.__promptSubmitValue = event.detail; + }) as EventListener); + }); + + const textarea = page.locator('ix-chat-input').locator('textarea'); + await textarea.focus(); + await textarea.press('Shift+Enter'); + expect(await page.evaluate(() => globalThis.__promptSubmitValue)).toBe( + undefined + ); + + await textarea.fill('Analyze alarms'); + await textarea.press('Enter'); + + const submittedValue = await page.evaluate( + () => globalThis.__promptSubmitValue + ); + expect(submittedValue).toBe('Analyze alarms'); + } +); + +regressionTest( + 'ix-chat-input does not render default slot actions', + async ({ mount, page }) => { + await mount(''); + + const chatInput = page.locator('ix-chat-input'); + + await expect(chatInput.locator('.left-actions ix-icon-button')).toHaveCount( + 0 + ); + await expect( + chatInput.locator('.right-actions ix-icon-button') + ).toHaveCount(1); + } +); + +regressionTest( + 'ix-chat-input switches the submit action icon for the processing state', + async ({ mount, page }) => { + await mount(''); + + const chatInput = page.locator('ix-chat-input'); + const submitButton = chatInput.locator('ix-icon-button.submit-button'); + + await expect + .poll(() => submitButton.evaluate(getSubmitButtonIcon)) + .toBe(iconSendRightFilled); + + await chatInput.evaluate((element) => { + (element as HTMLIxChatInputElement).state = 'processing'; + }); + + await expect + .poll(() => submitButton.evaluate(getSubmitButtonIcon)) + .toBe(iconCircleStop); + } +); + +regressionTest( + 'ix-chat-input shows a soft character limit warning from the threshold', + async ({ mount, page }) => { + await mount( + '' + ); + + const chatInput = page.locator('ix-chat-input'); + + await expect(chatInput.locator('.character-limit')).toContainText( + "You're nearing the limit (9 / 10 characters)" + ); + expect(await chatInput.locator('textarea').getAttribute('maxlength')).toBe( + '10' + ); + } +); + +regressionTest( + 'ix-chat-input supports a translated soft character limit warning', + async ({ mount, page }) => { + await mount(` + + `); + + const chatInput = page.locator('ix-chat-input'); + + await expect(chatInput.locator('.character-limit')).toContainText( + '10 Zeichen, 9 eingegeben' + ); + } +); + +regressionTest( + 'ix-chat-input supports a translated soft character limit warning with custom placeholders', + async ({ mount, page }) => { + await mount(` + + `); + + const chatInput = page.locator('ix-chat-input'); + + await expect(chatInput.locator('.character-limit')).toContainText( + 'Warning a limit is near!' + ); + } +); + +regressionTest( + 'ix-chat-input shows an error when the character limit is reached', + async ({ mount, page }) => { + await mount(''); + + const chatInput = page.locator('ix-chat-input'); + const textarea = chatInput.locator('textarea'); + + await textarea.fill('1234567890'); + + await expect(textarea).toHaveValue('1234567890'); + const characterLimit = chatInput.locator('.character-limit'); + await expect(characterLimit).toHaveClass(/character-limit--hard/); + await expect(characterLimit).toContainText( + 'Character limit reached (10 / 10 characters)' + ); + } +); + +regressionTest( + 'ix-chat-input supports a translated hard character limit message', + async ({ mount, page }) => { + await mount(` + + `); + + const chatInput = page.locator('ix-chat-input'); + const textarea = chatInput.locator('textarea'); + + await textarea.fill('1234567890'); + + await expect(chatInput.locator('.character-limit')).toContainText( + '10 von 10 Zeichen erreicht' + ); + } +); + +regressionTest( + 'ix-chat-input renders slotted attachments with selected layout', + async ({ mount, page }) => { + await mount(` + + + + + `); + + const chatInput = page.locator('ix-chat-input'); + + await expect(chatInput.locator('.attachments')).toHaveClass( + /has-attachments/ + ); + await expect(chatInput.locator('.attachments')).toHaveClass( + /attachments--scroll/ + ); + await expect(chatInput.locator('.attachments')).not.toHaveClass( + /has-attachment-scrollbar/ + ); + await expect( + page.locator('ix-chat-prompt-attachment[slot="attachments"]') + ).toHaveCount(2); + } +); + +regressionTest( + 'ix-chat-input adds scrollbar spacing only when attachments are scrollable', + async ({ mount, page }) => { + await mount(` + + ${Array.from( + { length: 8 }, + (_, index) => + `` + ).join('')} + + `); + + const chatInput = page.locator('ix-chat-input'); + const attachments = chatInput.locator('.attachments'); + + await expect(attachments).toHaveClass(/has-attachment-scrollbar/); + await expect(attachments).toHaveCSS('padding-bottom', '4px'); + + await chatInput.evaluate((element) => { + element.style.width = '90rem'; + element.style.setProperty('--ix-chat-input-max-width', '90rem'); + }); + + await expect(attachments).not.toHaveClass(/has-attachment-scrollbar/); + } +); + +regressionTest( + 'ix-chat-input does not render an attachment overflow dropdown', + async ({ mount, page }) => { + await mount(` + + ${Array.from( + { length: 10 }, + (_, index) => + `` + ).join('')} + + `); + + const chatInput = page.locator('ix-chat-input'); + await expect( + chatInput.locator('ix-dropdown-button.attachment-overflow') + ).toHaveCount(0); + await expect(chatInput.locator('.attachments')).not.toHaveClass( + /has-attachment-scrollbar/ + ); + await expect( + page.locator('[data-attachment-overflow-generated]') + ).toHaveCount(0); + await expect( + page.locator('ix-chat-prompt-attachment[slot="attachments"]') + ).toHaveCount(10); + } +); + +regressionTest( + 'ix-chat-input renders slotted follow-up actions above the input', + async ({ mount, page }) => { + await mount(` + + + What are the risks if this insight is ignored? + Show related insights from similar customer events. + + `); + + const chatInput = page.locator('ix-chat-input'); + const followUp = chatInput.locator('.follow-up-prompts'); + + await expect(chatInput).toHaveClass(/has-follow-up/); + await expect(followUp).toBeVisible(); + await expect(page.locator('[slot="follow-up"]')).toHaveCount(3); + await expect( + page.locator('ix-button[slot="follow-up"]').first() + ).toContainText('What are the risks if this insight is ignored?'); + } +); + +regressionTest(`form-ready - ix-chat-input`, async ({ mount, page }) => { + await mount( + `` + ); + + const formElement = page.locator('form'); + preventFormSubmission(formElement); + const input = page.locator('ix-chat-input').locator('textarea'); + await input.fill('Some prompt'); + await input.blur(); + + const formData = await getFormValue(formElement, 'my-field-name', page); + expect(formData).toBe('Some prompt'); +}); diff --git a/packages/core/src/components/chat-prompt-attachment/chat-prompt-attachment.scss b/packages/core/src/components/chat-prompt-attachment/chat-prompt-attachment.scss new file mode 100644 index 00000000000..b55c2637be6 --- /dev/null +++ b/packages/core/src/components/chat-prompt-attachment/chat-prompt-attachment.scss @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +@use 'mixins/shadow-dom/component'; + +:host { + display: inline-block; + + @include component.ix-component; + + .attachment-chip { + display: block; + width: 100%; + max-width: 20rem; + } + + .content, + .status-content { + display: inline-flex; + align-items: center; + min-width: 0; + } + + .content { + flex: 1 1 auto; + max-width: 100%; + width: 100%; + } + + .status-content { + gap: var(--theme-space-1); + } + + .status-content ix-spinner { + scale: 1.7; + margin-left: 0.1875rem; + } + + .file-name { + display: inline-flex; + flex: 1 1 auto; + min-width: 0; + max-width: 100%; + overflow: hidden; + } + + .file-name__base { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: end; + } + + .file-name__extension { + flex: 0 0 auto; + white-space: nowrap; + } + + .status-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + ix-icon { + flex: 0 0 auto; + } + + ix-spinner { + flex: 0 0 auto; + } +} + +:host(.preview-supported) { + cursor: pointer; +} + +:host(.has-remove-button) { + gap: var(--theme-space-0); +} diff --git a/packages/core/src/components/chat-prompt-attachment/chat-prompt-attachment.tsx b/packages/core/src/components/chat-prompt-attachment/chat-prompt-attachment.tsx new file mode 100644 index 00000000000..416c2024171 --- /dev/null +++ b/packages/core/src/components/chat-prompt-attachment/chat-prompt-attachment.tsx @@ -0,0 +1,177 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { iconError, iconTxtDocument } from '@siemens/ix-icons/icons'; +import { Component, Event, EventEmitter, Host, Prop, h } from '@stencil/core'; +import type { ChatPromptAttachmentStatus } from './chat-prompt-attachment.types'; + +/** + * @since 5.1.0 + */ +@Component({ + tag: 'ix-chat-prompt-attachment', + styleUrl: 'chat-prompt-attachment.scss', + shadow: true, +}) +export class ChatPromptAttachment { + /** + * Name of the attached file. + * @since 5.1.0 + */ + @Prop() fileName: string = ''; + + /** + * Upload status of the attachment. + * @since 5.1.0 + */ + @Prop({ reflect: true }) status: ChatPromptAttachmentStatus = 'default'; + + /** + * Icon displayed before the file name. + * @since 5.1.0 + */ + @Prop() icon: string = iconTxtDocument; + + /** + * Hide the remove action. + * @since 5.1.0 + */ + @Prop() hideRemoveButton: boolean = false; + + /** + * Enable preview interaction for default attachments. + * @since 5.1.0 + */ + @Prop({ reflect: true }) previewSupported: boolean = false; + + /** + * Accessible label for the remove action. + * @since 5.1.0 + */ + @Prop() removeAriaLabel: string = 'Remove attachment'; + + /** + * Event emitted when the attachment is clicked. + * @since 5.1.0 + */ + @Event() attachmentClick!: EventEmitter; + + /** + * Event emitted when the remove action is clicked. + * @since 5.1.0 + */ + @Event() removeClick!: EventEmitter; + + private canPreview() { + return this.previewSupported && this.status === 'default'; + } + + private splitFileName() { + const fileName = this.fileName.trim(); + const extensionStart = fileName.lastIndexOf('.'); + + if (extensionStart <= 0 || extensionStart === fileName.length - 1) { + return { name: fileName, extension: '' }; + } + + return { + name: fileName.slice(0, extensionStart), + extension: fileName.slice(extensionStart), + }; + } + + private renderFileName() { + const { name, extension } = this.splitFileName(); + + return ( + + {name || this.fileName} + {!!extension && {extension}} + + ); + } + + private getChipVariant() { + if (this.status === 'failed') { + return 'alarm'; + } + + return 'neutral'; + } + + private renderChipContent() { + if (this.status === 'loading') { + return ( + + + {this.fileName} + + ); + } + + if (this.status === 'failed') { + return {this.fileName}; + } + + return this.renderFileName(); + } + + private handleAttachmentClick() { + if (this.canPreview()) { + this.attachmentClick.emit(); + } + } + + private getIcon() { + if (this.status === 'default') { + return this.icon; + } + + if (this.status === 'loading') { + return undefined; + } + + if (this.status === 'failed') { + return iconError; + } + + return this.icon; + } + + render() { + const isFailed = this.status === 'failed'; + const isLoading = this.status === 'loading'; + const canPreview = this.canPreview(); + return ( + + this.handleAttachmentClick()} + onCloseChip={() => this.removeClick.emit()} + inactive={!canPreview} + > + {this.renderChipContent()} + + + ); + } +} diff --git a/packages/core/src/components/chat-prompt-attachment/chat-prompt-attachment.types.ts b/packages/core/src/components/chat-prompt-attachment/chat-prompt-attachment.types.ts new file mode 100644 index 00000000000..8e96014633a --- /dev/null +++ b/packages/core/src/components/chat-prompt-attachment/chat-prompt-attachment.types.ts @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export type ChatPromptAttachmentStatus = 'default' | 'loading' | 'failed'; diff --git a/packages/core/src/components/chat-prompt-attachment/tests/chat-prompt-attachment.ct.ts b/packages/core/src/components/chat-prompt-attachment/tests/chat-prompt-attachment.ct.ts new file mode 100644 index 00000000000..8b86335123d --- /dev/null +++ b/packages/core/src/components/chat-prompt-attachment/tests/chat-prompt-attachment.ct.ts @@ -0,0 +1,188 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import { expect } from '@playwright/test'; +import { regressionTest } from '@utils/test'; + +declare global { + var __attachmentClicked: boolean | undefined; + var __attachmentRemoveClicked: boolean | undefined; +} + +regressionTest( + 'ix-chat-prompt-attachment renders a default file attachment', + async ({ mount, page }) => { + await mount( + '' + ); + + const attachment = page.locator('ix-chat-prompt-attachment'); + + await expect(attachment.locator('.file-name__base')).toContainText( + 'meeting_notes_summary_01' + ); + await expect(attachment.locator('.file-name__extension')).toContainText( + '.txt' + ); + await expect(attachment.locator('ix-chip')).toHaveCount(1); + await expect(attachment.locator('ix-chip')).toHaveJSProperty( + 'closable', + true + ); + } +); + +regressionTest( + 'ix-chat-prompt-attachment truncates long file names with an ellipsis', + async ({ mount, page }) => { + await mount( + '' + ); + + const attachment = page.locator('ix-chat-prompt-attachment'); + const fileNameBase = attachment.locator('.file-name__base'); + const fileNameExtension = attachment.locator('.file-name__extension'); + const closeButton = attachment.locator('ix-chip').locator('.chip-close'); + + await expect(fileNameBase).toContainText( + 'Long File Name of chat prompt attachment with many details' + ); + + const getOverflowState = () => + fileNameBase.evaluate((element) => { + const style = getComputedStyle(element); + + return { + clientWidth: element.clientWidth, + overflow: style.overflow, + rectWidth: element.getBoundingClientRect().width, + scrollWidth: element.scrollWidth, + textOverflow: style.textOverflow, + whiteSpace: style.whiteSpace, + }; + }); + + await expect + .poll(async () => { + const state = await getOverflowState(); + return state.scrollWidth > state.clientWidth && state.clientWidth > 0; + }) + .toBe(true); + + const overflowState = await getOverflowState(); + expect(overflowState.overflow).toBe('hidden'); + expect(overflowState.textOverflow).toBe('ellipsis'); + expect(overflowState.whiteSpace).toBe('nowrap'); + + await expect(fileNameExtension).toContainText('.txt'); + await expect(closeButton).toBeVisible(); + } +); + +regressionTest( + 'ix-chat-prompt-attachment renders loading and failed states', + async ({ mount, page }) => { + await mount(` + + + `); + + await expect( + page.locator('ix-chat-prompt-attachment').first().locator('ix-spinner') + ).toHaveCount(1); + await expect( + page.locator('ix-chat-prompt-attachment').first() + ).toContainText('uploading_file.txt'); + + await expect( + page.locator('ix-chat-prompt-attachment').nth(1) + ).toContainText('failed_file.txt'); + await expect( + page.locator('ix-chat-prompt-attachment').nth(1).locator('ix-chip') + ).toHaveAttribute('variant', 'alarm'); + } +); + +regressionTest( + 'ix-chat-prompt-attachment emits attachmentClick only when preview is supported', + async ({ mount, page }) => { + await mount(` + + + `); + + await page.evaluate(() => { + globalThis.__attachmentClicked = false; + document + .querySelectorAll('ix-chat-prompt-attachment') + .forEach((attachment) => { + attachment.addEventListener('attachmentClick', () => { + globalThis.__attachmentClicked = true; + }); + }); + }); + + const staticAttachment = page.locator('ix-chat-prompt-attachment').first(); + const previewAttachment = page.locator('ix-chat-prompt-attachment').nth(1); + + await staticAttachment.click(); + expect(await page.evaluate(() => globalThis.__attachmentClicked)).toBe( + false + ); + + await previewAttachment.click(); + expect(await page.evaluate(() => globalThis.__attachmentClicked)).toBe( + true + ); + } +); + +regressionTest( + 'ix-chat-prompt-attachment renders sent variant compactly', + async ({ mount, page }) => { + await mount( + '' + ); + + const attachment = page.locator('ix-chat-prompt-attachment'); + + await expect(attachment.locator('ix-chip')).toHaveAttribute( + 'variant', + 'neutral' + ); + await expect(attachment.locator('ix-chip')).toHaveJSProperty( + 'closable', + false + ); + } +); + +regressionTest( + 'ix-chat-prompt-attachment emits removeClick from the chip close action', + async ({ mount, page }) => { + await mount( + '' + ); + + await page.evaluate(() => { + globalThis.__attachmentRemoveClicked = false; + const attachment = document.querySelector('ix-chat-prompt-attachment'); + + attachment?.addEventListener('removeClick', () => { + globalThis.__attachmentRemoveClicked = true; + }); + }); + + const attachment = page.locator('ix-chat-prompt-attachment'); + await attachment.locator('ix-chip').locator('.chip-close').click(); + + await expect + .poll(() => page.evaluate(() => globalThis.__attachmentRemoveClicked)) + .toBe(true); + } +); diff --git a/packages/core/src/components/chat-user-message/chat-user-message.scss b/packages/core/src/components/chat-user-message/chat-user-message.scss new file mode 100644 index 00000000000..aa7997ae67e --- /dev/null +++ b/packages/core/src/components/chat-user-message/chat-user-message.scss @@ -0,0 +1,99 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +@use 'mixins/shadow-dom/component'; +@use './mixins/action.mixin'; + +:host { + display: flex; + flex-direction: column; + align-items: flex-end; + width: 100%; + min-width: 0; + box-sizing: border-box; + + @include component.ix-component; + + .message { + width: fit-content; + min-width: max(15.375rem, 45%); + max-width: 80%; + box-sizing: border-box; + padding: var(--theme-space-2) var(--theme-space-3); + border-radius: var(--theme-default-border-radius); + background-color: var(--theme-color-component-8); + color: var(--theme-color-std-text); + } + + .attachments { + display: none; + justify-content: flex-end; + flex-wrap: wrap; + gap: var(--theme-space-1) 0; + width: 100%; + max-width: 75%; + margin-bottom: var(--theme-space-1); + overflow: hidden; + } + + .attachments ::slotted(ix-chat-prompt-attachment) { + flex: 0 1 6.375rem; + padding-left: var(--theme-space-1); + } + + .attachment-overflow { + margin-bottom: var(--theme-space-1); + --ix-dropdown-button-border-radius-left: var(--theme-small-border-radius); + --ix-dropdown-button-border-radius-right: var(--theme-small-border-radius); + } + + .message-text { + display: block; + overflow-wrap: anywhere; + white-space: pre-wrap; + } + + .actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--theme-space-1); + min-height: 2rem; + margin-top: var(--theme-space-1); + + @include action.animate-action; + } + + .actions ::slotted(ix-icon-button) { + flex: 0 0 auto; + } +} + +:host(.has-attachments) { + .attachments { + display: flex; + } +} + +:host(:focus-visible) { + outline: none; + + .message { + outline: 1px solid var(--theme-color-focus-bdr); + outline-offset: var(--theme-focus-outline-offset); + } +} + +:host(.has-actions:focus), +:host(.has-actions:hover), +:host(.has-actions:focus-within) { + .actions { + @include action.show-action; + } +} diff --git a/packages/core/src/components/chat-user-message/chat-user-message.tsx b/packages/core/src/components/chat-user-message/chat-user-message.tsx new file mode 100644 index 00000000000..ddd0dbebdae --- /dev/null +++ b/packages/core/src/components/chat-user-message/chat-user-message.tsx @@ -0,0 +1,121 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Component, Element, Host, Prop, State, h } from '@stencil/core'; + +/** + * @since 5.1.0 + * @slot - Message content displayed in the user message bubble + * @slot actions - Optional actions displayed below the user message bubble + * @slot attachments - ix-chat-prompt-attachment elements with variant="sent" displayed above the user message bubble + */ +@Component({ + tag: 'ix-chat-user-message', + styleUrl: 'chat-user-message.scss', + shadow: true, +}) +export class ChatUserMessage { + @Element() hostElement!: HTMLIxChatUserMessageElement; + + /** + * Text displayed in the user message bubble. + * @since 5.1.0 + */ + @Prop() message?: string; + + @State() hasActions = false; + @State() hasAttachments = false; + @State() hasMessageContent = false; + + componentWillLoad() { + this.updateHasMessageContent(); + } + + private handleActionsSlotChange(event: Event) { + const slot = event.target as HTMLSlotElement; + this.hasActions = + slot.assignedElements({ + flatten: true, + }).length > 0; + } + + private handleAttachmentsSlotChange(event: Event) { + const slot = event.target as HTMLSlotElement; + this.hasAttachments = + slot.assignedElements({ + flatten: true, + }).length > 0; + } + + private handleMessageSlotChange(event: Event) { + this.updateHasMessageContent(event.target as HTMLSlotElement); + } + + private hasAssignedMessageContent(slot?: HTMLSlotElement) { + if (slot) { + return slot.assignedNodes({ flatten: true }).some((node) => { + return node.nodeType === 1 || !!node.textContent?.trim(); + }); + } + + return Array.from(this.hostElement.childNodes).some((node) => { + if (node.nodeType === 1) { + return (node as HTMLElement).slot === ''; + } + + return !!node.textContent?.trim(); + }); + } + + private updateHasMessageContent(slot?: HTMLSlotElement) { + this.hasMessageContent = this.hasAssignedMessageContent(slot); + } + + render() { + return ( + +
+ this.handleAttachmentsSlotChange(event)} + > +
+
+ + {this.message} + + this.handleMessageSlotChange(event)} + > + + +
+
+ this.handleActionsSlotChange(event)} + > +
+
+ ); + } +} diff --git a/packages/core/src/components/chat-user-message/mixins/action.mixin.scss b/packages/core/src/components/chat-user-message/mixins/action.mixin.scss new file mode 100644 index 00000000000..2bb3070319b --- /dev/null +++ b/packages/core/src/components/chat-user-message/mixins/action.mixin.scss @@ -0,0 +1,8 @@ +@mixin animate-action() { + opacity: 0; + transition: opacity 0.2s ease-in-out; +} + +@mixin show-action() { + opacity: 1; +} diff --git a/packages/core/src/components/chat-user-message/tests/chat-user-message.ct.ts b/packages/core/src/components/chat-user-message/tests/chat-user-message.ct.ts new file mode 100644 index 00000000000..a8155bf9f31 --- /dev/null +++ b/packages/core/src/components/chat-user-message/tests/chat-user-message.ct.ts @@ -0,0 +1,147 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import { expect } from '@playwright/test'; +import { regressionTest } from '@utils/test'; + +regressionTest( + 'ix-chat-user-message renders a user message from property', + async ({ mount, page }) => { + await mount( + '' + ); + + const message = page.locator('ix-chat-user-message'); + + await expect(message.locator('.message-text')).toContainText( + 'Summarize the detailed discussion held with the customer' + ); + await expect(message.locator('.actions')).toHaveCount(1); + await expect(message.locator('.actions')).not.toBeVisible(); + } +); + +regressionTest( + 'ix-chat-user-message renders slotted message content', + async ({ mount, page }) => { + await mount( + 'Analyze alarms' + ); + + const message = page.locator('ix-chat-user-message'); + + await expect(message.locator('strong')).toContainText('Analyze alarms'); + } +); + +regressionTest( + 'ix-chat-user-message renders property and slotted message content together', + async ({ mount, page }) => { + await mount( + ' and show trends' + ); + + const message = page.locator('ix-chat-user-message'); + + await expect(message.locator('.message-text')).toContainText( + 'Analyze alarms' + ); + await expect(page.locator('ix-chat-user-message > span')).toContainText( + 'and show trends' + ); + } +); + +regressionTest( + 'ix-chat-user-message renders optional actions', + async ({ mount, page }) => { + await mount(` + + + + + `); + + const message = page.locator('ix-chat-user-message'); + + await expect(message.locator('.message-text')).toContainText( + 'Analyze alarms' + ); + await expect(message).toHaveClass(/has-actions/); + await expect(message.locator('.actions')).toBeVisible(); + await expect(page.locator('button[slot="actions"]')).toHaveCount(2); + } +); + +regressionTest( + 'ix-chat-user-message ignores whitespace from named slotted content', + async ({ mount, page }) => { + await mount(` + + + + + `); + + const message = page.locator('ix-chat-user-message'); + + await expect(message.locator('.message-text')).toHaveText('Analyze alarms'); + } +); + +regressionTest( + 'ix-chat-user-message reveals actions on hover', + async ({ mount, page }) => { + await mount(` + + + + `); + + const message = page.locator('ix-chat-user-message'); + const actions = message.locator('.actions'); + + await expect(actions).not.toBeVisible(); + + await message.hover(); + + await expect(actions).toBeVisible(); + } +); + +regressionTest( + 'ix-chat-user-message renders slotted attachments', + async ({ mount, page }) => { + await mount(` + + + + `); + + await page.evaluate(() => { + const attachment = document.querySelector('ix-chat-prompt-attachment'); + attachment?.addEventListener('attachmentClick', () => { + attachment.setAttribute('data-clicked', 'true'); + }); + }); + + const message = page.locator('ix-chat-user-message'); + const attachment = page.locator( + 'ix-chat-prompt-attachment[slot="attachments"]' + ); + + await expect(message).toHaveClass(/has-attachments/); + await expect(message.locator('.attachments')).toBeVisible(); + await expect(attachment).toHaveCount(1); + await expect(attachment).toHaveAttribute('variant', 'sent'); + + await attachment.click(); + + await expect(attachment).toHaveAttribute('data-clicked', 'true'); + } +); diff --git a/packages/core/src/components/chat/chat.scss b/packages/core/src/components/chat/chat.scss new file mode 100644 index 00000000000..9f601dfd925 --- /dev/null +++ b/packages/core/src/components/chat/chat.scss @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +@use 'mixins/shadow-dom/component'; + +:host { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-height: 0; + min-width: 27rem; + max-width: calc(45rem + var(--theme-space-5)); + box-sizing: border-box; + + @include component.ix-component; + + .messages { + display: flex; + flex: 1 1 auto; + flex-direction: column; + align-items: center; + gap: var(--theme-space-5); + min-height: 0; + width: 100%; + padding: var(--theme-space-5); + box-sizing: border-box; + overflow-y: auto; + } + + .messages ::slotted(*) { + width: 100%; + max-width: 45rem; + flex: 0 0 auto; + } + + .prompt { + display: none; + flex: 0 0 auto; + width: 100%; + padding: var(--theme-space-5); + box-sizing: border-box; + } + + .prompt ::slotted(*) { + width: 100%; + max-width: 45rem; + } +} + +:host(.has-prompt) { + .prompt { + display: flex; + justify-content: center; + } +} diff --git a/packages/core/src/components/chat/chat.tsx b/packages/core/src/components/chat/chat.tsx new file mode 100644 index 00000000000..5447ce4a4e5 --- /dev/null +++ b/packages/core/src/components/chat/chat.tsx @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Component, Host, State, h } from '@stencil/core'; + +/** + * @since 5.1.0 + * @slot - Chat messages, for example ix-chat-user-message and ix-chat-ai-message + * @slot prompt - Chat input displayed below the chat messages + */ +@Component({ + tag: 'ix-chat', + styleUrl: 'chat.scss', + shadow: true, +}) +export class Chat { + @State() hasPrompt = false; + + private hasAssignedContent(slot: HTMLSlotElement) { + return slot.assignedNodes({ flatten: true }).some((node) => { + return node.nodeType === 1 || !!node.textContent?.trim(); + }); + } + + private handlePromptSlotChange(event: Event) { + this.hasPrompt = this.hasAssignedContent(event.target as HTMLSlotElement); + } + + render() { + return ( + +
+ +
+
+ this.handlePromptSlotChange(event)} + > +
+
+ ); + } +} diff --git a/packages/core/src/components/chat/tests/chat.ct.ts b/packages/core/src/components/chat/tests/chat.ct.ts new file mode 100644 index 00000000000..2ab4bac9654 --- /dev/null +++ b/packages/core/src/components/chat/tests/chat.ct.ts @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import { expect } from '@playwright/test'; +import { regressionTest } from '@utils/test'; + +regressionTest( + 'ix-chat renders chat messages and chat input', + async ({ mount, page }) => { + await mount(` + + +

Effective asset performance management improves uptime.

+
+ + +
+ `); + + const chat = page.locator('ix-chat'); + + await expect(chat).toHaveClass(/has-prompt/); + await expect(chat.locator('.messages')).toBeVisible(); + await expect(chat.locator('.prompt')).toBeVisible(); + await expect(page.locator('ix-chat-ai-message')).toContainText( + 'Effective asset performance management improves uptime.' + ); + await expect(page.locator('ix-chat-user-message')).toContainText( + 'Summarize the detailed discussion held with the customer' + ); + await expect(page.locator('ix-chat-input[slot="prompt"]')).toHaveCount(1); + } +); + +regressionTest( + 'ix-chat hides prompt layout when no prompt is assigned', + async ({ mount, page }) => { + await mount(` + + +

Analyze alarms.

+
+
+ `); + + const chat = page.locator('ix-chat'); + + await expect(chat).not.toHaveClass(/has-prompt/); + await expect(chat.locator('.messages')).toBeVisible(); + await expect(chat.locator('.prompt')).not.toBeVisible(); + } +); + +regressionTest( + 'ix-chat provides a scrollable message area', + async ({ mount, page }) => { + await mount(` + + +

Analyze alarms.

+
+ +
+ `); + + await expect(page.locator('ix-chat').locator('.messages')).toHaveCSS( + 'overflow-y', + 'auto' + ); + } +); diff --git a/packages/core/src/components/chip/chip.scss b/packages/core/src/components/chip/chip.scss index a30363e4bfc..dc1937db855 100644 --- a/packages/core/src/components/chip/chip.scss +++ b/packages/core/src/components/chip/chip.scss @@ -71,10 +71,6 @@ $outline-border-width: 0.0625rem; @include component.ix-component; } -:host(.inactive) { - pointer-events: none; -} - .chip-wrap { display: inline-flex; width: inherit; @@ -125,6 +121,13 @@ $outline-border-width: 0.0625rem; .slot-container { @include text-truncation.ellipsis; + flex: 0 1 auto; + min-width: 0; + + slot::slotted(*) { + max-width: 100%; + min-width: 0; + } } } @@ -133,16 +136,19 @@ $outline-border-width: 0.0625rem; text-align: center; } + &.inactive .chip-main { + pointer-events: none; + } + &.outline .chip-main { padding-left: calc(#{$padding-inline} - #{$outline-border-width}); } - &.outline:not(.closable) .chip-main, - &.outline.closable.inactive .chip-main { + &.outline:not(.closable) .chip-main { padding-right: calc(#{$padding-inline} - #{$outline-border-width}); } - &.outline.closable:not(.inactive) .chip-main { + &.outline.closable .chip-main { padding-right: calc( #{$close-inset} + #{$close-size} + 0.375rem - #{$outline-border-width} ); @@ -152,12 +158,11 @@ $outline-border-width: 0.0625rem; padding-left: $padding-inline; } - &:not(.outline):not(.closable) .chip-main, - &:not(.outline).closable.inactive .chip-main { + &:not(.outline):not(.closable) .chip-main { padding-right: $padding-inline; } - &:not(.outline).closable:not(.inactive) .chip-main { + &:not(.outline).closable .chip-main { padding-right: calc(#{$close-inset} + #{$close-size} + 0.375rem); } @@ -384,3 +389,9 @@ $outline-border-width: 0.0625rem; } } } + +:host(.attachment-chip) { + .chip-wrap { + border-radius: var(--theme-btn--border-radius); + } +} diff --git a/packages/core/src/components/chip/chip.tsx b/packages/core/src/components/chip/chip.tsx index 2f8d3104ba4..d06aef11bfe 100644 --- a/packages/core/src/components/chip/chip.tsx +++ b/packages/core/src/components/chip/chip.tsx @@ -46,7 +46,7 @@ export class Chip /** * Determines if the chip is interactive. If true no user input (e.g. mouse states, keyboard navigation) - * will be possible and also the close button will not be present. + * will be possible on the main chip content. */ @Prop() inactive = false; @@ -187,7 +187,6 @@ export class Chip const { wrap: customWrapStyle, main: customMainStyle } = this.getCustomStyles(variant); - const showClose = !this.inactive && this.closable; const wrapClasses = { 'chip-wrap': true, outline: this.outline, @@ -214,7 +213,7 @@ export class Chip const hasTooltip = !!this.tooltipText || this.hostElement.hasAttribute('tooltip-text'); - const needsGroupRole = hasAccessibleName && (showClose || hasTooltip); + const needsGroupRole = hasAccessibleName && (this.closable || hasTooltip); return ( - {showClose && ( + {this.closable && (