diff --git a/apps/website/pages/components/message-input/code.tsx b/apps/website/pages/components/message-input/code.tsx
new file mode 100644
index 000000000..c5934bfb7
--- /dev/null
+++ b/apps/website/pages/components/message-input/code.tsx
@@ -0,0 +1,17 @@
+import Head from "next/head";
+import type { ReactElement } from "react";
+import MessageInputCodePage from "screens/components/message-input/code/MessageInputCodePage";
+import MessageInputPageLayout from "screens/components/message-input/MessageInputPageLayout";
+
+const Code = () => (
+ <>
+
+ Message Input code — Halstack Design System
+
+
+ >
+);
+
+Code.getLayout = (page: ReactElement) => {page};
+
+export default Code;
diff --git a/apps/website/pages/components/message-input/index.tsx b/apps/website/pages/components/message-input/index.tsx
new file mode 100644
index 000000000..5a5c8b355
--- /dev/null
+++ b/apps/website/pages/components/message-input/index.tsx
@@ -0,0 +1,17 @@
+import Head from "next/head";
+import type { ReactElement } from "react";
+import MessageInputPageLayout from "screens/components/message-input/MessageInputPageLayout";
+import MessageInputOverviewPage from "screens/components/message-input/overview/MessageInputOverviewPage";
+
+const Index = () => (
+ <>
+
+ Message Input — Halstack Design System
+
+
+ >
+);
+
+Index.getLayout = (page: ReactElement) => {page};
+
+export default Index;
diff --git a/apps/website/screens/common/componentsList.json b/apps/website/screens/common/componentsList.json
index 5969856e1..818d91ffb 100644
--- a/apps/website/screens/common/componentsList.json
+++ b/apps/website/screens/common/componentsList.json
@@ -141,6 +141,12 @@
"status": "stable",
"icon": "filled_file_upload"
},
+ {
+ "label": "Message input",
+ "path": "/components/message-input",
+ "status": "experimental",
+ "icon": "chat_bubble"
+ },
{
"label": "Number input",
"path": "/components/number-input",
@@ -271,7 +277,7 @@
"label": "Popover",
"path": "/components/popover",
"status": "experimental",
- "icon": "chat_bubble"
+ "icon": "chat_bubble"
}
]
},
diff --git a/apps/website/screens/components/message-input/MessageInputPageLayout.tsx b/apps/website/screens/components/message-input/MessageInputPageLayout.tsx
new file mode 100644
index 000000000..bbf4a11c6
--- /dev/null
+++ b/apps/website/screens/components/message-input/MessageInputPageLayout.tsx
@@ -0,0 +1,30 @@
+import { DxcParagraph, DxcFlex } from "@dxc-technology/halstack-react";
+import PageHeading from "@/common/PageHeading";
+import TabsPageHeading from "@/common/TabsPageLayout";
+import ComponentHeading from "@/common/ComponentHeading";
+import { ReactNode } from "react";
+
+const MessageInputPageHeading = ({ children }: { children: ReactNode }) => {
+ const tabs = [
+ { label: "Overview", path: "/components/message-input" },
+ { label: "Code", path: "/components/message-input/code" },
+ ];
+
+ return (
+
+
+
+
+
+ Message inputs are composition components specifically designed to capture and send user messages in{" "}
+ conversational interfaces, both in human-to-human messaging and AI-assisted applications.
+
+
+
+
+ {children}
+
+ );
+};
+
+export default MessageInputPageHeading;
diff --git a/apps/website/screens/components/message-input/code/MessageInputCodePage.tsx b/apps/website/screens/components/message-input/code/MessageInputCodePage.tsx
new file mode 100644
index 000000000..9520b24bf
--- /dev/null
+++ b/apps/website/screens/components/message-input/code/MessageInputCodePage.tsx
@@ -0,0 +1,259 @@
+import { DxcTable, DxcFlex, DxcLink } from "@dxc-technology/halstack-react";
+import Link from "next/link";
+import DocFooter from "@/common/DocFooter";
+import QuickNavContainer from "@/common/QuickNavContainer";
+import Code, { ExtendedTableCode, TableCode } from "@/common/Code";
+import Example from "@/common/example/Example";
+import controlled from "./examples/controlled";
+import uncontrolled from "./examples/uncontrolled";
+import advanced from "./examples/advanced";
+
+const selectOptionsTypeString = `{
+ label?: string;
+ value: string;
+ onSelect: (value: string) => void;
+ selected?: boolean;
+}[]`;
+const onButtonClickTypeString = `(val: {
+ type: "submit" | "stop";
+ value?: string;
+ files?: File[];
+ selectedOption?: SelectOption;
+ }) => void;
+`;
+
+const sections = [
+ {
+ title: "Props",
+ content: (
+
+
+
+
Name
+
Type
+
Description
+
Default
+
+
+
+
+
allowRecording
+
+ boolean
+
+
+ If true, the voice recording button will be shown. In order to change the language of the transcription
+ functionality, use the localeTag prop from the{" "}
+
+ Halstack Provider
+
+ , by default if a Halstack Provider is not used, the language will be set to "en-US".
+
+
+ false
+
+
+
+
callbackFile
+
+ {"(files: File[]) => void"}
+
+
+ This function will be called when the selection of top items changes. If this function is provided the
+ message input will allow file selection.
+
+
-
+
+
+
defaultValue
+
+ string
+
+
Initial value of the input, only when it is uncontrolled.
+
-
+
+
+
disabled
+
+ boolean
+
+
If true, the component will be disabled.
+
+ false
+
+
+
+
error
+
+ string
+
+
+ If it is a defined value and also a truthy string, the component will change its appearance, showing the
+ error below the input component. If the defined value is an empty string, it will reserve a space below
+ the component for a future error, but it would not change its look. In case of being undefined or null,
+ both the appearance and the space for the error message would not be modified.
+
+
-
+
+
+
files
+
+ {"File[] | []"}
+
+
Items to be shown at the top.
+
-
+
+
+
isGenerating
+
+ boolean
+
+
If true, it indicates that a request is being processed after the user submits a query.
+
+ false
+
+
+
+
maxLength
+
+ number
+
+
+ Specifies the maximum length allowed by the input. This will be checked both when the input element loses
+ the focus and while typing within it. If the string entered does not comply the maximum length, the onBlur
+ and onChange functions will be called with the current value and an internal error informing that the
+ value length does not comply the specified range. If a valid length is reached, the error parameter of
+ both events will not be defined.
+
+
-
+
+
+
minLength
+
+ number
+
+
+ Specifies the minimum length allowed by the input. This will be checked aboth when the input element loses
+ the focus and while typing within it. If the string entered does not comply the minimum length, the onBlur
+ and onChange functions will be called with the current value and an internal error informing that the
+ value length does not comply the specified range. If a valid length is reached, the error parameter of
+ both events will not be defined.
+
+
-
+
+
+
selectOptions
+
+ {selectOptionsTypeString}
+
+
Options to be shown on the dropdown under the input.
+ This function will be called when the input element loses the focus. An object including the input value
+ and the error (if the value entered is not valid) will be passed to this function. If there is no error,
+ error will not be defined.
+
+
-
+
+
+
onButtonClick
+
+ {onButtonClickTypeString}
+
+
+ This function will be called when the user clicks on the button (submit or stop) or presses enter. The
+ type parameter indicates whether it's a 'submit' or 'stop' event. For submit
+ events, 'value', 'files'and 'selectedOption' are provided.
+
+ This function will be called when the user types within the input element of the component. An object
+ including the current value and the error (if the value entered is not valid) will be passed to this
+ function. If there is no error, error will not be defined.
+
Specifies the size of the component. The size will affect the width of the input.
+
+ 'medium'
+
+
+
+
tabIndex
+
+ number
+
+
+ Value of the tabindex attribute.
+
+
-
+
+
+
value
+
+ string
+
+
+ Value of the input. If undefined, the component will be uncontrolled and the value will be managed
+ internally by the component.
+
+
-
+
+
+
+ ),
+ },
+ {
+ title: "Examples",
+ subSections: [
+ {
+ title: "Uncontrolled",
+ content: ,
+ },
+ {
+ title: "Controlled",
+ content: ,
+ },
+ {
+ title: "Advanced",
+ content: ,
+ },
+ ],
+ },
+];
+
+const MessageInputCodePage = () => {
+ return (
+
+
+
+
+ );
+};
+
+export default MessageInputCodePage;
diff --git a/apps/website/screens/components/message-input/code/examples/advanced.tsx b/apps/website/screens/components/message-input/code/examples/advanced.tsx
new file mode 100644
index 000000000..7aa97210c
--- /dev/null
+++ b/apps/website/screens/components/message-input/code/examples/advanced.tsx
@@ -0,0 +1,99 @@
+import { DxcMessageInput, DxcInset, DxcFlex, DxcButton } from "@dxc-technology/halstack-react";
+import { useState } from "react";
+
+const code = `() => {
+ const [value, setValue] = useState("");
+ const [files, setFiles] = useState();
+ const [selectedOption, setSelectedOptions] = useState("model-1.0");
+ const [isGenerating, setIsGenerating] = useState(false);
+ const [error, setError] = useState("");
+
+ const onChange = ({ value, error }) => {
+ setValue(value);
+ setError(error || "");
+ };
+
+ const onBlur = ({ value, error }) => {
+ if (error) {
+ setError(error);
+ }
+ };
+
+ const onButtonClick = async ({type}) => {
+ if (type === "submit") {
+ setIsGenerating(true);
+ console.log("Submitting message:", value);
+ console.log("Attached files:", files);
+ console.log("Selected model:", selectedOption);
+
+ // Simulate async operation
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+
+ setIsGenerating(false);
+ setValue("");
+ setFiles([]);
+ } else if (type === "stop") {
+ console.log("Stopping generation");
+ setIsGenerating(false);
+ }
+ };
+
+ const callbackFile = (updatedFiles) => {
+ setFiles(updatedFiles);
+ };
+
+ const selectOptions = [
+ {
+ label: "MODEL-1.0",
+ icon: "psychology",
+ value: "model-1.0",
+ onSelect: () => setSelectedOptions("model-1.0"),
+ selected: selectedOption === "model-1.0"
+ },
+ {
+ label: "MODEL-3.5",
+ icon: "smart_toy",
+ value: "model-3.5",
+ onSelect: () => setSelectedOptions("model-3.5"),
+ selected: selectedOption === "model-3.5"
+ },
+ {
+ label: "MODEL-4+",
+ icon: "lightbulb",
+ value: "model-4+",
+ onSelect: () => setSelectedOptions("model-4+"),
+ selected: selectedOption === "model-4+"
+ }
+ ];
+
+ return (
+
+
+
+ );
+}`;
+
+const scope = {
+ DxcMessageInput,
+ DxcInset,
+ DxcFlex,
+ DxcButton,
+ useState,
+};
+
+export default { code, scope };
diff --git a/apps/website/screens/components/message-input/code/examples/controlled.tsx b/apps/website/screens/components/message-input/code/examples/controlled.tsx
new file mode 100644
index 000000000..c19837a57
--- /dev/null
+++ b/apps/website/screens/components/message-input/code/examples/controlled.tsx
@@ -0,0 +1,47 @@
+import { DxcMessageInput, DxcInset } from "@dxc-technology/halstack-react";
+import { useState } from "react";
+
+const code = `() => {
+ const [value, setValue] = useState("");
+ const [error, setError] = useState();
+
+ const onChange = ({ value }) => {
+ setValue(value);
+ };
+
+ const onBlur = ({ value, error }) => {
+ setError(error)
+ };
+
+ const onButtonClick = async ({type}) => {
+ if (type === "submit") {
+ console.log("Submitted message:", value);
+ // Simulate async operation
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ setValue(""); // Clear input after submit
+ }
+ };
+
+ return (
+
+
+
+ );
+}`;
+
+const scope = {
+ DxcMessageInput,
+ DxcInset,
+ useState,
+};
+
+export default { code, scope };
diff --git a/apps/website/screens/components/message-input/code/examples/uncontrolled.tsx b/apps/website/screens/components/message-input/code/examples/uncontrolled.tsx
new file mode 100644
index 000000000..a3abac1ad
--- /dev/null
+++ b/apps/website/screens/components/message-input/code/examples/uncontrolled.tsx
@@ -0,0 +1,40 @@
+import { DxcMessageInput, DxcButton, DxcFlex, DxcInset } from "@dxc-technology/halstack-react";
+import { useRef } from "react";
+
+const code = `() => {
+ const onChange = ({ value }) => {
+ setValue(value);
+ };
+
+ const onBlur = ({ value }) => {
+ console.log("Input blurred with value:", value);
+ };
+
+ const onButtonClick = async ({type, value}) => {
+ if (type === "submit") {
+ console.log("Submitted message:", value);
+ // Simulate async operation
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ setValue(""); // Clear input after submit
+ }
+ };
+
+ return (
+
+
+
+ );
+}`;
+
+const scope = {
+ DxcMessageInput,
+ DxcButton,
+ DxcFlex,
+ DxcInset,
+ useRef,
+};
+
+export default { code, scope };
diff --git a/apps/website/screens/components/message-input/overview/MessageInputOverviewPage.tsx b/apps/website/screens/components/message-input/overview/MessageInputOverviewPage.tsx
new file mode 100644
index 000000000..4bed803f2
--- /dev/null
+++ b/apps/website/screens/components/message-input/overview/MessageInputOverviewPage.tsx
@@ -0,0 +1,320 @@
+import QuickNavContainer from "@/common/QuickNavContainer";
+import DxcFlex from "../../../../../../packages/lib/src/flex/Flex";
+import DocFooter from "@/common/DocFooter";
+import DxcParagraph from "../../../../../../packages/lib/src/paragraph/Paragraph";
+import { DxcBulletedList } from "@dxc-technology/halstack-react";
+import Figure from "@/common/Figure";
+import Image from "@/common/Image";
+import messageInputAnatomy from "./images/message-input-anatomy.png";
+import messageInputExample from "./images/message-input-example.png";
+import generatingState from "./images/generating-state.png";
+
+const sections = [
+ {
+ title: "Overview",
+ content: (
+ <>
+
+ The Message input provides a structured composition area for conversational interfaces. It
+ combines a text input field, an optional context zone for attachments and
+ selections, and a persistent action bar with submission controls. Common use cases include
+ chat applications, customer support tools, AI assistants, and any interface where users compose and send
+ messages or queries.
+
+
+ Unlike our text area, which is a general-purpose multi-line text field for form contexts, the{" "}
+ message input is built around the interaction model of sending a message. It exposes
+ configurable zones for attached context, a left action slot for secondary selections, and a voice input
+ toggle, and it includes a dedicated generating state for AI interfaces that need to communicate when the
+ system is processing a response.
+
+
+ Proper use of placeholder text, the available states, and the optional zones can significantly improve the
+ usability and clarity of any conversational interface.
+
+ >
+ ),
+ },
+ {
+ title: "Anatomy",
+ content: (
+ <>
+
+
+
+ Dropdown(optional): allows the user to attach files, either as context for the AI
+ or as files to send in a conversation or message.
+
+
+ Attachments (optional): A chip or set of chips that reflects the selection made
+ by the dropdown immediately to its left. It represents the context attachments the user can add for the
+ agent, or any file they want to include with the message.
+
+
+ Send button: the primary submission control, anchored to the bottom right of the action
+ bar. Transitions to a stop or cancel affordance in the generating state.
+
+
+ Container: the visual wrapper of the component, defining its boundary, background, and
+ border. Border color changes to reflect the current interaction state.
+
+
+ Model Selector(optional): a dropdown anchored to the bottom-left corner of the
+ action bar. Intended for selections that shape how the message is processed, such as a model selector or a
+ conversation mode picker.
+
+
+ Text input area: the main composition field. Displays placeholder text when empty and grows
+ vertically as the user types.
+
+
+ Dictation button(optional): a voice input trigger positioned to the left of the
+ send button that lets user dictate their query instead of typing. It should only be visible when voice input
+ is functional in the target context.
+
+
+ >
+ ),
+ },
+ {
+ title: "Conversational inputs",
+ content: (
+ <>
+
+ The message input is designed for conversational UI patterns, where the primary user goal is
+ to compose and submit a message or query. Unlike traditional form inputs, it is not intended for data
+ collection in structured forms.
+
+
+ A message input always requires a send action to complete the interaction. It may optionally include a
+ contextual zone for attachments and a left action slot for secondary selections that affect how the message is
+ processed.
+
+ >
+ ),
+ subSections: [
+ {
+ title: "Shared input characteristics",
+ content: (
+ <>
+
+ Although the Message input differs from standard form inputs, it shares some common configurable features:
+
+
+
+ Placeholder text: a short hint displayed inside the text input area that describes its
+ purpose or expected content.
+
+
+ Helper text: additional guidance displayed below the component to help the user
+ understand constraints or formatting requirements.
+
+
+ Optional elements: the top actions zone, left action slot, and voice button are all
+ optional and can be toggled independently to fit the needs of each interface.
+
+
+ >
+ ),
+ },
+ {
+ title: "Common input states",
+ content: (
+ <>
+
+ The message input supports the following standard interactive and informative states:
+
+
+
+ Disabled: prevents user interaction. Use when the input is not applicable or editable
+ under certain conditions, such as when permissions are insufficient or the interface is in a read-only
+ mode.
+
+
+ Error: applied when the input fails validation or a submission results in a system
+ error. The border switches to the error color and the error message zone below the container becomes
+ visible.
+
+
+ Read-only: the field is visible and focusable but not editable. Suitable for displaying
+ a message that cannot be modified.
+
+
+ >
+ ),
+ },
+ ],
+ },
+ {
+ title: "Using message inputs",
+ content: (
+ <>
+
+ The Message input is highly configurable, allowing teams to adapt it to both simple messaging interfaces and
+ complex AI-assisted applications. This section highlights the key behaviors and zones of the component.
+
+
+
+
+ >
+ ),
+ subSections: [
+ {
+ title: "Top actions zone",
+ content: (
+ <>
+
+ The top actions zone provides space for contextual chips and a dropdown. When enabled, it
+ appears as a horizontally scrollable strip above the text input. Chips in this zone represent items
+ attached to or scoping the current message, such as uploaded files or active filters.
+
+
+ Users can dismiss individual chips using the close action on each one. When the number of chips exceeds
+ the available width, the strip scrolls horizontally.
+
+ >
+ ),
+ },
+ {
+ title: "Left action slot",
+ content: (
+ <>
+
+ The left action slot hosts an optional selector at the bottom left of the action bar. Its purpose is to
+ provide a secondary selection that affects how the message is processed, not to trigger an action.
+ Examples include model selectors, conversation mode pickers, and language selectors.
+
+ >
+ ),
+ },
+ {
+ title: "Action buttons",
+ content: (
+ <>
+
+ The action bar's right side contains the send button, and optionally the{" "}
+ voice button.
+
+
+
+ The send button is always visible and submits the composed message.
+
+
+ The voice button provides a voice input trigger. It should only be shown when dictation
+ is supported and operational in the target context. Displaying it without functional voice capability
+ creates a broken affordance.
+
+
+ >
+ ),
+ },
+ {
+ title: "Generating state",
+ content: (
+ <>
+
+ The generating state is specific to AI-assisted interfaces. It locks the send button to
+ prevent duplicate submissions while the system processes a response, and transitions the button to a stop
+ or cancel affordance. The text input area remains accessible so users can draft their next message while
+ waiting.
+
+
+
+
+
+ >
+ ),
+ },
+ {
+ title: "Best practices",
+ subSections: [
+ {
+ title: "General",
+ content: (
+ <>
+
+
+ Use this component for message composition only: The message input carries
+ conversational connotations by design. Using it in standard form contexts, such as notes fields,
+ description inputs, or feedback forms, creates mismatched expectations. Use our text area component
+ for those cases.
+
+
+ Write placeholder text that sets scope: avoid generic strings like "Type here" or
+ "Write something...". Prefer specific strings that reflect the capability of the interface, such as
+ "Ask about your policy documents". Keep it short enough to be absorbed at a glance.
+
+
+ >
+ ),
+ },
+ {
+ title: "Top actions",
+ content: (
+ <>
+
+
+ Keep chips focused on the current message: chips should represent items directly
+ tied to the message being composed, such as attached files or active filters. Do not use this zone
+ for persistent session state or navigation elements unrelated to the current input.
+
+
+ Avoid overloading the context selector: the context selector dropdown in the top
+ zone should offer a focused set of options. If the number of options grows large, consider using a
+ separate selection pattern before the user enters the composition area.
+
+
+ >
+ ),
+ },
+ {
+ title: "Model selector",
+ content: (
+ <>
+
+
+ Reserve this slot for context-setting selections: the left action slot is intended
+ for selections that shape the message, not for buttons or navigation elements. Model selectors, mode
+ pickers, and language selectors are appropriate. Overloading this slot with primary controls creates
+ ambiguity about the message flow.
+
+
+ >
+ ),
+ },
+ {
+ title: "AI contexts",
+ content: (
+ <>
+
+
+ Use the generating state instead of disabling the component: disabling the field
+ during AI processing removes the ability to draft the next message while waiting for a response. The
+ generating state locks submission while keeping the composition area accessible. Reserve disabled
+ for genuinely unavailable contexts.
+
+
+ Write actionable error messages: the error string should tell the user what to do,
+ not just what went wrong. "Maximum 2,000 characters" is more useful than "Input too long". If the
+ error originates from a system failure, acknowledge it clearly and offer a next step.
+
+
+ >
+ ),
+ },
+ ],
+ },
+ ],
+ },
+];
+
+const MessageInputOverviewPage = () => {
+ return (
+
+
+
+
+ );
+};
+
+export default MessageInputOverviewPage;
diff --git a/apps/website/screens/components/message-input/overview/images/generating-state.png b/apps/website/screens/components/message-input/overview/images/generating-state.png
new file mode 100644
index 000000000..15d708262
Binary files /dev/null and b/apps/website/screens/components/message-input/overview/images/generating-state.png differ
diff --git a/apps/website/screens/components/message-input/overview/images/message-input-anatomy.png b/apps/website/screens/components/message-input/overview/images/message-input-anatomy.png
new file mode 100644
index 000000000..260b326e5
Binary files /dev/null and b/apps/website/screens/components/message-input/overview/images/message-input-anatomy.png differ
diff --git a/apps/website/screens/components/message-input/overview/images/message-input-example.png b/apps/website/screens/components/message-input/overview/images/message-input-example.png
new file mode 100644
index 000000000..21109fa45
Binary files /dev/null and b/apps/website/screens/components/message-input/overview/images/message-input-example.png differ
diff --git a/apps/website/screens/guidelines/localization/LocalizationPage.tsx b/apps/website/screens/guidelines/localization/LocalizationPage.tsx
index b51bc31f2..a546d8564 100644
--- a/apps/website/screens/guidelines/localization/LocalizationPage.tsx
+++ b/apps/website/screens/guidelines/localization/LocalizationPage.tsx
@@ -392,6 +392,58 @@ const sections = [
),
},
+ {
+ title: "messageInput",
+ content: (
+
+
+
+
Label Name
+
Default value
+
Details
+
+
+
+
+
+ inputAriaLabel
+
+
Write a message
+
+
+
+ sendButtonTitle
+
+
Send message
+
+
+
+ stopButtonTitle
+
+
Stop
+
+
+
+ attachFileButtonTitle
+
+
Attach file
+
+
+
+ recordAudioButtonTitle
+
+
Record audio
+
+
+
+ stopRecordingButtonTitle
+
+
Stop recording
+
+
+
+ ),
+ },
{
title: "numberInput",
content: (
diff --git a/packages/lib/src/common/variables.ts b/packages/lib/src/common/variables.ts
index 23e8a1697..98a2caf42 100644
--- a/packages/lib/src/common/variables.ts
+++ b/packages/lib/src/common/variables.ts
@@ -82,6 +82,14 @@ export const defaultTranslatedComponentLabels = {
closeIcon: "Close menu",
hamburgerTitle: "Menu",
},
+ messageInput: {
+ inputAriaLabel: "Message input",
+ sendButtonTitle: "Send message",
+ stopButtonTitle: "Stop request",
+ attachFileButtonTitle: "Attach file",
+ recordAudioButtonTitle: "Record audio",
+ stopRecordingButtonTitle: "Stop recording",
+ },
numberInput: {
valueGreaterThanOrEqualToErrorMessage: (value: number) => `Value must be greater than or equal to ${value}.`,
valueLessThanOrEqualToErrorMessage: (value: number) => `Value must be less than or equal to ${value}.`,
diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts
index ed05c0296..e776fe13c 100644
--- a/packages/lib/src/index.ts
+++ b/packages/lib/src/index.ts
@@ -27,6 +27,7 @@ export { default as DxcHeading } from "./heading/Heading";
export { default as DxcImage } from "./image/Image";
export { default as DxcInset } from "./inset/Inset";
export { default as DxcLink } from "./link/Link";
+export { default as DxcMessageInput } from "./message-input/MessageInput";
export { default as DxcNavTabs } from "./nav-tabs/NavTabs";
export { default as DxcNumberInput } from "./number-input/NumberInput";
export { default as DxcPaginator } from "./paginator/Paginator";
diff --git a/packages/lib/src/message-input/MessageInput.stories.tsx b/packages/lib/src/message-input/MessageInput.stories.tsx
new file mode 100644
index 000000000..c7c27ae13
--- /dev/null
+++ b/packages/lib/src/message-input/MessageInput.stories.tsx
@@ -0,0 +1,105 @@
+import { Meta, StoryObj } from "@storybook/react-vite";
+import DxcMessageInput from "./MessageInput";
+import Title from "../../.storybook/components/Title";
+import ExampleContainer from "../../.storybook/components/ExampleContainer";
+
+export default {
+ title: "Message Input",
+ component: DxcMessageInput,
+} satisfies Meta;
+
+type Story = StoryObj;
+
+const selectOptionsOptions = [
+ { label: "GPT-4", value: "gpt4", onSelect: () => {} },
+ { label: "Claude Sonnet", value: "claude", onSelect: () => {}, selected: true },
+ { label: "Gemini Pro", value: "gemini", onSelect: () => {} },
+ { label: "LLaMA 3", value: "llama", onSelect: () => {} },
+];
+
+const files = [
+ new File([""], "document.pdf", { type: "application/pdf" }),
+ new File([""], "image.jpg", { type: "image/jpeg" }),
+ new File([""], "document.pdf", { type: "application/pdf" }),
+ new File([""], "image.jpg", { type: "image/jpeg" }),
+];
+
+const MessageInput = () => (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ console.log("added file")}
+ />
+
+
+ {/* ================== STATES ================== */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+);
+
+export const Chromatic: Story = {
+ render: MessageInput,
+};
diff --git a/packages/lib/src/message-input/MessageInput.test.tsx b/packages/lib/src/message-input/MessageInput.test.tsx
new file mode 100644
index 000000000..10284a204
--- /dev/null
+++ b/packages/lib/src/message-input/MessageInput.test.tsx
@@ -0,0 +1,496 @@
+import "@testing-library/jest-dom";
+import { act, fireEvent, render, renderHook } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import DxcMessageInput from "./MessageInput";
+import {
+ ISpeechRecognition,
+ SpeechRecognitionAlternative,
+ SpeechRecognitionErrorEvent,
+ SpeechRecognitionEvent,
+ SpeechRecognitionResultItem,
+ SpeechRecognitionResultList,
+ useVoiceTranscription,
+ WindowWithSpeechRecognition,
+} from "./useVoiceTranscription";
+
+// Mock ResizeObserver
+global.ResizeObserver = jest.fn().mockImplementation(() => ({
+ observe: jest.fn(),
+ unobserve: jest.fn(),
+ disconnect: jest.fn(),
+}));
+
+// Mock Speech Recognition
+class MockSpeechRecognition extends EventTarget implements ISpeechRecognition {
+ lang = "";
+ continuous = false;
+ interimResults = false;
+ onresult: ((event: SpeechRecognitionEvent) => void) | null = null;
+ onerror: ((event: SpeechRecognitionErrorEvent) => void) | null = null;
+ onend: (() => void) | null = null;
+
+ start = jest.fn();
+ stop = jest.fn(() => {
+ this.onend?.();
+ });
+
+ emitResult(transcript: string, confidence: number) {
+ const alternative: SpeechRecognitionAlternative = { transcript, confidence };
+ const resultItem: SpeechRecognitionResultItem = { length: 1, 0: alternative };
+ const results: SpeechRecognitionResultList = { length: 1, 0: resultItem };
+
+ this.onresult?.(Object.assign(new Event("result"), { results }));
+ }
+
+ emitEnd() {
+ this.onend?.();
+ }
+
+ emitError(error: string) {
+ this.onerror?.(Object.assign(new Event("error"), { error }));
+ }
+}
+
+describe("Message Input component tests", () => {
+ test("Message Input renders correctly", () => {
+ const { getByPlaceholderText } = render();
+ const input = getByPlaceholderText("Ask me anything...");
+ expect(input).toBeInTheDocument();
+ });
+
+ test("renders with default value", () => {
+ const { getByDisplayValue } = render();
+ expect(getByDisplayValue("Hello World")).toBeInTheDocument();
+ });
+
+ test("renders with error message", () => {
+ const { getByText } = render();
+ expect(getByText("This is an error")).toBeInTheDocument();
+ });
+
+ test("calls onChange when user types", () => {
+ const onChange = jest.fn();
+ const { getByRole } = render();
+ const input = getByRole("textbox");
+
+ userEvent.type(input, "Hello");
+
+ expect(onChange).toHaveBeenCalledTimes(5);
+ expect(onChange).toHaveBeenLastCalledWith({ value: "Hello" });
+ });
+
+ test("calls onBlur when input loses focus", () => {
+ const onBlur = jest.fn();
+ const { getByRole } = render();
+ const input = getByRole("textbox");
+
+ userEvent.click(input);
+ userEvent.type(input, "Test");
+ userEvent.tab();
+
+ expect(onBlur).toHaveBeenCalledWith({ value: "Test" });
+ });
+
+ test("calls onButtonClick when Enter key is pressed", () => {
+ const onButtonClick = jest.fn();
+ const { getByRole } = render();
+ const input = getByRole("textbox");
+
+ userEvent.click(input);
+ userEvent.type(input, "Test message{Enter}");
+
+ expect(onButtonClick).toHaveBeenCalledWith({ type: "submit", value: "Test message" });
+ expect(onButtonClick).toHaveBeenCalledTimes(1);
+ });
+
+ test("does not call onButtonClick when Shift+Enter is pressed", () => {
+ const onButtonClick = jest.fn();
+ const { getByRole } = render();
+ const input = getByRole("textbox");
+
+ userEvent.click(input);
+ userEvent.type(input, "Line 1{Shift>}{Enter}{/Shift}Line 2");
+
+ expect(onButtonClick).not.toHaveBeenCalled();
+ });
+
+ test("does not call onButtonClick when Enter is pressed and component is disabled", () => {
+ const onButtonClick = jest.fn();
+ const { getByRole } = render();
+ const input = getByRole("textbox");
+
+ userEvent.type(input, "Test message{Enter}");
+
+ expect(onButtonClick).not.toHaveBeenCalled();
+ });
+
+ test("does not call onButtonClick when Enter is pressed and isGenerating is true", () => {
+ const onButtonClick = jest.fn();
+ const { getByRole } = render();
+ const input = getByRole("textbox");
+
+ userEvent.type(input, "Test message{Enter}");
+
+ expect(onButtonClick).not.toHaveBeenCalled();
+ });
+
+ test("calls onButtonClick when submit button is clicked", () => {
+ const onButtonClick = jest.fn();
+ const { getByLabelText } = render();
+ const submitButton = getByLabelText("Send message");
+
+ userEvent.click(submitButton);
+
+ expect(onButtonClick).toHaveBeenCalledWith({ type: "submit", value: "" });
+ expect(onButtonClick).toHaveBeenCalledTimes(1);
+ });
+
+ test("disables input when disabled prop is true", () => {
+ const { getByRole } = render();
+ const input = getByRole("textbox");
+
+ expect(input).toBeDisabled();
+ });
+
+ test("disables input when isGenerating is true", () => {
+ const { getByRole } = render();
+ const input = getByRole("textbox");
+
+ expect(input).toBeDisabled();
+ });
+
+ test("shows stop button when isGenerating is true", () => {
+ const onButtonClick = jest.fn();
+ const { getByLabelText } = render();
+ const stopButton = getByLabelText("Stop request");
+
+ expect(stopButton).toBeInTheDocument();
+ });
+
+ test("calls onButtonClick with 'stop' when stop button is clicked", () => {
+ const onButtonClick = jest.fn();
+ const { getByLabelText } = render();
+ const stopButton = getByLabelText("Stop request");
+
+ userEvent.click(stopButton);
+
+ expect(onButtonClick).toHaveBeenCalledWith({ type: "stop" });
+ expect(onButtonClick).toHaveBeenCalledTimes(1);
+ });
+
+ test("Length constraint", () => {
+ const onChange = jest.fn();
+ const onBlur = jest.fn();
+ const { getByRole } = render();
+ const input = getByRole("textbox");
+ fireEvent.change(input, { target: { value: "test" } });
+ expect(onChange).toHaveBeenCalledWith({
+ value: "test",
+ error: "The minimum length is 5.",
+ });
+ fireEvent.blur(input);
+ expect(onBlur).toHaveBeenCalledWith({
+ value: "test",
+ error: "The minimum length is 5.",
+ });
+
+ fireEvent.change(input, { target: { value: "test-maximum-length" } });
+ expect(onChange).toHaveBeenCalledWith({
+ value: "test-maximum-length",
+ error: "The maximum length is 10.",
+ });
+ fireEvent.blur(input);
+ expect(onBlur).toHaveBeenCalledWith({
+ value: "test-maximum-length",
+ error: "The maximum length is 10.",
+ });
+
+ fireEvent.change(input, { target: { value: "length" } });
+ expect(onChange).toHaveBeenCalledWith({ value: "length" });
+ fireEvent.blur(input);
+ expect(onBlur).toHaveBeenCalledWith({ value: "length" });
+ });
+
+ test("validates maxLength attribute is set", () => {
+ const { getByRole } = render();
+ const input = getByRole("textbox");
+
+ expect(input).toHaveAttribute("maxLength", "10");
+ });
+
+ test("works as controlled component", () => {
+ const onChange = jest.fn();
+ const { getByRole, rerender } = render();
+ const input = getByRole("textbox");
+
+ expect(input).toHaveValue("Initial");
+
+ userEvent.type(input, " text");
+
+ rerender();
+ expect(input).toHaveValue("Initial text");
+ });
+
+ test("renders file upload dropdown when files prop is provided", () => {
+ const { getByRole } = render( console.log("")} />);
+ const dropdown = getByRole("button", { name: "Show options" });
+
+ expect(dropdown).toBeInTheDocument();
+ });
+
+ test("renders bottom select when selectOptions is provided", () => {
+ const selectOptions = [
+ { label: "Option 1", value: "option1", onSelect: jest.fn() },
+ { label: "Option 2", value: "option2", onSelect: jest.fn() },
+ ];
+ const { getByRole } = render();
+ const select = getByRole("combobox");
+
+ expect(select).toBeInTheDocument();
+ });
+
+ test("calls onSelect when a select option is selected", () => {
+ const onSelect = jest.fn();
+ const selectOptions = [
+ { label: "Option 1", value: "option1", onSelect },
+ { label: "Option 2", value: "option2", onSelect: jest.fn() },
+ ];
+ const { getByRole } = render();
+ const select = getByRole("combobox");
+
+ userEvent.click(select);
+ const option1 = getByRole("option", { name: "Option 1" });
+ userEvent.click(option1);
+
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ expect(onSelect).toHaveBeenCalledWith("option1");
+ });
+
+ test("does not show error when disabled", () => {
+ const { queryByText } = render();
+ expect(queryByText("This is an error")).not.toBeInTheDocument();
+ });
+
+ test("does not call onButtonClick when disabled", () => {
+ const onButtonClick = jest.fn();
+ const { getByLabelText } = render();
+ const submitButton = getByLabelText("Send message");
+
+ userEvent.click(submitButton);
+
+ expect(onButtonClick).not.toHaveBeenCalled();
+ });
+
+ test("shows stop button instead of submit when isGenerating", () => {
+ const onButtonClick = jest.fn();
+ const { getByLabelText } = render();
+
+ // The button should be the stop button when isGenerating
+ const button = getByLabelText("Stop request");
+ expect(button).toBeInTheDocument();
+ });
+
+ test("handles file selection", () => {
+ const { getByRole } = render( console.log("")} />);
+ const dropdown = getByRole("button", { name: "Show options" });
+
+ // Simulate selecting the option
+ userEvent.click(dropdown);
+
+ expect(dropdown).toBeInTheDocument();
+ });
+
+ test("adds files when files are selected", () => {
+ const callbackFile = jest.fn();
+ const { container } = render();
+
+ const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
+ expect(fileInput).toBeInTheDocument();
+
+ const file = new File(["content"], "test.txt", { type: "text/plain" });
+ Object.defineProperty(fileInput, "files", {
+ value: [file],
+ writable: false,
+ });
+
+ fileInput.dispatchEvent(new Event("change", { bubbles: true }));
+
+ expect(callbackFile).toHaveBeenCalled();
+ });
+
+ test("calls onButtonClick with submit when submit button is clicked", () => {
+ const onButtonClick = jest.fn();
+ const { getByLabelText } = render();
+ const submitButton = getByLabelText("Send message");
+
+ userEvent.click(submitButton);
+ expect(onButtonClick).toHaveBeenCalledWith({ type: "submit", value: "" });
+ expect(onButtonClick).toHaveBeenCalledTimes(1);
+ });
+
+ test("works with file uploads", () => {
+ const callbackFile = jest.fn();
+ const { container } = render();
+
+ const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
+ const file = new File(["content"], "test.txt", { type: "text/plain" });
+
+ Object.defineProperty(fileInput, "files", {
+ value: [file],
+ writable: false,
+ });
+
+ fileInput.dispatchEvent(new Event("change", { bubbles: true }));
+
+ expect(callbackFile).toHaveBeenCalled();
+ });
+
+ let mockInstance: MockSpeechRecognition;
+
+ beforeEach(() => {
+ mockInstance = new MockSpeechRecognition();
+ (window as WindowWithSpeechRecognition).SpeechRecognition = jest.fn(() => mockInstance);
+ });
+
+ afterEach(() => {
+ delete (window as WindowWithSpeechRecognition).SpeechRecognition;
+ jest.clearAllMocks();
+ });
+
+ test("starts and stops recording when button is clicked", () => {
+ const { container } = render();
+ const recordButton = container.querySelector('[aria-label="Record audio"]') as HTMLButtonElement;
+
+ // Start recording
+ fireEvent.click(recordButton);
+ expect(mockInstance.start).toHaveBeenCalled();
+
+ // Emit some transcript
+ act(() => {
+ mockInstance.emitResult("Test transcript", 0.9);
+ });
+
+ // Stop recording by clicking button again
+ const stopButton = container.querySelector('[aria-label="Stop recording"]') as HTMLButtonElement;
+ fireEvent.click(stopButton);
+ expect(mockInstance.stop).toHaveBeenCalled();
+ });
+});
+
+describe("useVoiceTranscription", () => {
+ let mockInstance: MockSpeechRecognition;
+
+ beforeEach(() => {
+ mockInstance = new MockSpeechRecognition();
+ (window as WindowWithSpeechRecognition).SpeechRecognition = jest.fn(() => mockInstance);
+ });
+
+ afterEach(() => {
+ delete (window as WindowWithSpeechRecognition).SpeechRecognition;
+ jest.clearAllMocks();
+ });
+
+ test("detects support when SpeechRecognition exists", () => {
+ const { result } = renderHook(() => useVoiceTranscription({ lang: "es-ES" }));
+ expect(result.current.isSupported).toBe(true);
+ });
+
+ test("starts recording and sets isRecording to true", () => {
+ const { result } = renderHook(() => useVoiceTranscription({ lang: "es-ES" }));
+
+ act(() => {
+ result.current.startRecording();
+ });
+
+ expect(mockInstance.start).toHaveBeenCalled();
+ expect(result.current.isRecording).toBe(true);
+ });
+
+ test("updates transcript when confidence is high enough", () => {
+ const { result } = renderHook(() => useVoiceTranscription({ lang: "en-US" }));
+
+ act(() => {
+ result.current.startRecording();
+ });
+
+ act(() => {
+ mockInstance.emitResult("hello world", 0.8);
+ });
+
+ expect(result.current.transcript).toBe("hello world");
+ });
+
+ test("ignores transcript when confidence is below 0.5", () => {
+ const { result } = renderHook(() => useVoiceTranscription({ lang: "en-US" }));
+
+ act(() => {
+ result.current.startRecording();
+ });
+
+ act(() => {
+ mockInstance.emitResult("text with low confidence", 0.2);
+ });
+
+ expect(result.current.transcript).toBe("");
+ });
+
+ test("resets transcript and isRecording when recognition ends automatically", () => {
+ const { result } = renderHook(() => useVoiceTranscription({ lang: "eb-US" }));
+
+ act(() => {
+ result.current.startRecording();
+ });
+
+ act(() => {
+ mockInstance.emitResult("hello", 0.9);
+ });
+
+ expect(result.current.transcript).toBe("hello");
+
+ act(() => {
+ mockInstance.emitEnd();
+ });
+
+ expect(result.current.isRecording).toBe(false);
+ expect(result.current.transcript).toBe("");
+ });
+
+ test("resets transcript on error", () => {
+ const { result } = renderHook(() => useVoiceTranscription({ lang: "en-US" }));
+
+ act(() => {
+ result.current.startRecording();
+ });
+
+ act(() => {
+ mockInstance.emitResult("hello", 0.9);
+ });
+
+ act(() => {
+ mockInstance.emitError("no-speech");
+ });
+
+ expect(result.current.isRecording).toBe(false);
+ expect(result.current.transcript).toBe("");
+ });
+
+ test("stopRecording calls recognition.stop", () => {
+ const { result } = renderHook(() => useVoiceTranscription({ lang: "en-US" }));
+
+ act(() => {
+ result.current.startRecording();
+ });
+
+ act(() => {
+ result.current.stopRecording();
+ });
+
+ expect(mockInstance.stop).toHaveBeenCalled();
+ });
+
+ test("isSupported is false when SpeechRecognition is not available", () => {
+ delete (window as WindowWithSpeechRecognition).SpeechRecognition;
+ const { result } = renderHook(() => useVoiceTranscription({ lang: "en-US" }));
+ expect(result.current.isSupported).toBe(false);
+ });
+});
diff --git a/packages/lib/src/message-input/MessageInput.tsx b/packages/lib/src/message-input/MessageInput.tsx
new file mode 100644
index 000000000..f3f97e520
--- /dev/null
+++ b/packages/lib/src/message-input/MessageInput.tsx
@@ -0,0 +1,428 @@
+import {
+ ChangeEvent,
+ FocusEvent,
+ KeyboardEvent,
+ MouseEvent,
+ useContext,
+ useEffect,
+ useId,
+ useRef,
+ useState,
+} from "react";
+import styled from "@emotion/styled";
+import scrollbarStyles from "../styles/scroll";
+import PromptInputPropsType from "./types";
+import { getFilePreview, getSelectedOption, inputStylesByStatePromptInput } from "./utils";
+import DxcButton from "../button/Button";
+import DxcChip from "../chip/Chip";
+import DxcContainer from "../container/Container";
+import DxcDropdown from "../dropdown/Dropdown";
+import DxcFlex from "../flex/Flex";
+import { HalstackLanguageContext } from "../HalstackContext";
+import ErrorMessage from "../styles/forms/ErrorMessage";
+import { useVoiceTranscription } from "./useVoiceTranscription";
+import DxcSelect from "../select/Select";
+import { getLengthErrorMessage } from "../common/utils";
+import DxcTypography from "../typography/Typography";
+
+const sizes = {
+ small: "240px",
+ medium: "360px",
+ large: "480px",
+ fillParent: "100%",
+} as const;
+
+const MessageInput = styled.div<{
+ disabled: Required["disabled"];
+ error: boolean;
+ focus: boolean;
+ hasFiles?: boolean;
+}>`
+ width: 100%;
+ max-height: 320px;
+ box-sizing: border-box;
+ position: relative;
+ display: grid;
+ grid-template-rows: ${({ hasFiles }) =>
+ hasFiles ? "minmax(36px, 40px) minmax(0, 1fr) 34px" : "minmax(0, 1fr) 34px"};
+ gap: var(--spacing-gap-s);
+ padding: var(--spacing-padding-m) var(--spacing-padding-xs);
+ box-shadow: 0 -24px 10px 4px rgba(255, 255, 255, 0.6);
+ ${({ disabled, error, focus }) => inputStylesByStatePromptInput(disabled, error, focus)}
+ overflow: hidden;
+`;
+
+const FilesContainer = styled.div`
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-gap-xs);
+ overflow-x: auto;
+ overflow-y: hidden;
+ padding-bottom: 4px;
+
+ ${scrollbarStyles};
+ &::-webkit-scrollbar {
+ height: 4px;
+ }
+`;
+
+const MessageArea = styled.textarea<{ isRecording?: boolean }>`
+ min-height: 20px;
+ width: 100%;
+ background: none;
+ border: none;
+ outline: none;
+ padding: var(--spacing-padding-none) var(--spacing-padding-xs);
+ field-sizing: content;
+ resize: none;
+ color: ${({ disabled, isRecording }) =>
+ isRecording ? "transparent" : disabled ? "var(--color-fg-neutral-medium)" : "var(--color-fg-neutral-dark)"};
+ font-family: var(--typography-font-family);
+ font-size: var(--typography-label-m);
+ font-weight: var(--typography-label-regular);
+ white-space: pre-wrap;
+ word-break: break-word;
+ overflow-y: auto;
+ box-sizing: border-box;
+
+ ::placeholder {
+ color: ${({ disabled }) => (disabled ? "var(--color-fg-neutral-medium)" : "var(--color-fg-neutral-strong)")};
+ }
+ ${({ disabled }) => disabled && "cursor: not-allowed;"}
+
+ ${scrollbarStyles};
+ &::-webkit-scrollbar {
+ width: 4px;
+ }
+`;
+
+const InputWrapper = styled.div`
+ position: relative;
+ display: flex;
+`;
+
+const TranscriptOverlay = styled.div`
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ pointer-events: none;
+ color: var(--color-fg-neutral-dark);
+ font-family: var(--typography-font-family);
+ font-size: var(--typography-label-m);
+ font-weight: var(--typography-label-regular);
+ white-space: pre-wrap;
+ word-break: break-word;
+ overflow-y: auto;
+ padding: var(--spacing-padding-none) var(--spacing-padding-xs);
+
+ ${scrollbarStyles};
+ &::-webkit-scrollbar {
+ width: 4px;
+ }
+`;
+
+const DxcMessageInput = ({
+ allowRecording = false,
+ callbackFile,
+ defaultValue = "",
+ disabled = false,
+ error,
+ files,
+ isGenerating = false,
+ maxLength,
+ minLength,
+ selectOptions,
+ onBlur,
+ onButtonClick,
+ onChange,
+ placeholder = "",
+ size = "medium",
+ tabIndex,
+ value,
+}: PromptInputPropsType) => {
+ const languageContext = useContext(HalstackLanguageContext);
+ const translatedLabels = languageContext.labels;
+ const locale = languageContext.locale ?? "en-US";
+ const inputId = `input-${useId()}`;
+ const inputRef = useRef(null);
+ const overlayRef = useRef(null);
+ const [innerValue, setInnerValue] = useState(defaultValue);
+ const [isFocused, setIsFocused] = useState(false);
+ const fileInputRef = useRef(null);
+ const {
+ transcript,
+ isRecording,
+ startRecording,
+ stopRecording,
+ resetTranscript,
+ error: transcriptError,
+ } = useVoiceTranscription({
+ lang: locale,
+ });
+ const dropdownOptions = [{ label: languageContext.labels.messageInput.attachFileButtonTitle, value: "fileorphoto" }];
+
+ const changeValue = (newValue: string) => {
+ if (value == null) setInnerValue(newValue);
+
+ const lengthError = getLengthErrorMessage({
+ value: newValue,
+ minLength,
+ maxLength,
+ minLengthErrorMessage: translatedLabels.formFields.minLengthErrorMessage,
+ maxLengthErrorMessage: translatedLabels.formFields.maxLengthErrorMessage,
+ });
+
+ if (lengthError) {
+ onChange?.({ value: newValue, error: lengthError });
+ } else if (transcriptError) {
+ onChange?.({ value: newValue, error: transcriptError });
+ } else {
+ onChange?.({ value: newValue });
+ }
+ };
+
+ const handleInputContainerOnClick = () => inputRef.current?.focus();
+
+ const handleInputContainerOnMouseDown = (event: MouseEvent) => {
+ if (document.activeElement === inputRef.current) event.preventDefault();
+ };
+
+ const handleInputOnChange = (event: ChangeEvent) => {
+ changeValue(event.target.value);
+ };
+
+ const handleInputOnFocus = () => setIsFocused(true);
+
+ const handleInputOnBlur = (event: FocusEvent) => {
+ setIsFocused(false);
+
+ const lengthError = getLengthErrorMessage({
+ value: event.target.value,
+ minLength,
+ maxLength,
+ minLengthErrorMessage: translatedLabels.formFields.minLengthErrorMessage,
+ maxLengthErrorMessage: translatedLabels.formFields.maxLengthErrorMessage,
+ });
+
+ if (lengthError) {
+ onBlur?.({ value: event.target.value, error: lengthError });
+ } else if (transcriptError) {
+ onBlur?.({ value: event.target.value, error: transcriptError });
+ } else {
+ onBlur?.({ value: event.target.value });
+ }
+ };
+
+ const handleFileSelect = () => fileInputRef.current?.click();
+
+ const handleFileInputOnChange = (event: ChangeEvent) => {
+ const selectedFiles = Array.from(event.target.files ?? []);
+ callbackFile?.([...(files ?? []), ...selectedFiles]);
+ event.target.value = "";
+ };
+
+ const removeItem = (itemIndex: number) => callbackFile?.((files ?? []).filter((_, index) => index !== itemIndex));
+
+ const baseTextRef = useRef("");
+
+ const toggleVoiceRecognition = () => {
+ if (disabled) return;
+
+ if (isRecording) {
+ stopRecording();
+ resetTranscript();
+ } else {
+ baseTextRef.current = value ?? innerValue;
+ startRecording();
+ }
+ };
+
+ // Handle transcript updates and auto-scroll to bottom
+ useEffect(() => {
+ if (!transcript) return;
+
+ const combined = baseTextRef.current ? `${baseTextRef.current} ${transcript}` : transcript;
+ changeValue(combined);
+
+ if (isRecording) {
+ const textarea = inputRef.current;
+ const overlay = overlayRef.current;
+ if (textarea && overlay) {
+ textarea.scrollTop = textarea.scrollHeight;
+ overlay.scrollTop = overlay.scrollHeight;
+ }
+ }
+ }, [transcript, changeValue, isRecording]);
+
+ // Synchronize overlay scroll with textarea when user manually scrolls
+ useEffect(() => {
+ const textarea = inputRef.current;
+ const overlay = overlayRef.current;
+ if (!textarea || !overlay || !isRecording) return;
+
+ overlay.scrollTop = textarea.scrollTop;
+ const handleScroll = () => (overlay.scrollTop = textarea.scrollTop);
+
+ textarea.addEventListener("scroll", handleScroll);
+ return () => textarea.removeEventListener("scroll", handleScroll);
+ }, [isRecording]);
+
+ const handleSubmit = () => {
+ if (!disabled && !isGenerating)
+ onButtonClick?.({
+ type: "submit",
+ value: value ?? innerValue,
+ files: files,
+ selectedOption: selectOptions && getSelectedOption(selectOptions),
+ });
+ };
+
+ const handleStop = () => {
+ if (!disabled) onButtonClick?.({ type: "stop" });
+ };
+
+ const handleInputOnKeyDown = (event: KeyboardEvent) => {
+ if (!disabled && !isGenerating && event.key === "Enter" && !event.shiftKey) {
+ event.preventDefault();
+ handleSubmit();
+ }
+ };
+
+ return (
+
+
+ {typeof callbackFile === "function" && (
+
+
+
+
+ {!disabled &&
+ (files ?? []).map((item, index) => (
+ removeItem(index)}
+ />
+ ))}
+
+ )}
+
+ {
+ event.stopPropagation();
+ }}
+ placeholder={placeholder}
+ ref={inputRef}
+ tabIndex={tabIndex}
+ maxLength={maxLength}
+ minLength={minLength}
+ value={value ?? innerValue}
+ />
+ {isRecording && (
+
+ {baseTextRef.current && {baseTextRef.current}}
+ {transcript && (
+ <>
+ {transcript.length > 2 ? (
+ <>
+ {transcript.slice(0, -2)}
+ {transcript.slice(-2)} ...
+ >
+ ) : (
+ {transcript}
+ )}
+ >
+ )}
+
+ )}
+
+
+ {selectOptions && (
+
+ ({ label: option.label ?? option.value, value: option.value }))}
+ disabled={isGenerating || disabled || isRecording}
+ value={selectOptions.find((option) => option.selected)?.value || selectOptions[0]?.value}
+ onChange={(val) => {
+ const newOption = selectOptions.find((option) => option.value === val.value);
+ newOption?.onSelect(val.value);
+ }}
+ />
+
+ )}
+
+
+ {allowRecording && (
+
+ )}
+
+
+
+
+
+ {!disabled && typeof error === "string" && }
+
+ );
+};
+
+export default DxcMessageInput;
diff --git a/packages/lib/src/message-input/types.ts b/packages/lib/src/message-input/types.ts
new file mode 100644
index 000000000..a6a49969b
--- /dev/null
+++ b/packages/lib/src/message-input/types.ts
@@ -0,0 +1,109 @@
+export type SelectOption = {
+ label?: string;
+ value: string;
+ onSelect: (value: string) => void;
+ selected?: boolean;
+};
+
+type Props = {
+ /**
+ * If true, the voice recording button will be shown.
+ */
+ allowRecording?: boolean;
+ /**
+ * This function will be called when the selection of top items changes.
+ * If this function is provided the message input will allow file selection.
+ */
+ callbackFile?: (files: File[]) => void;
+ /**
+ * Initial value of the input, only when it is uncontrolled.
+ */
+ defaultValue?: string;
+ /**
+ * If true, the component will be disabled.
+ */
+ disabled?: boolean;
+ /**
+ * If it is a defined value and also a truthy string, the component will
+ * change its appearance, showing the error below the input component. If
+ * the defined value is an empty string, it will reserve a space below
+ * the component for a future error, but it would not change its look. In
+ * case of being undefined or null, both the appearance and the space for
+ * the error message would not be modified.
+ */
+ error?: string;
+ /**
+ * Items to be shown at the top.
+ */
+ files?: File[];
+ /**
+ * If true, it indicates that a request is being processed after the user submits a query.
+ */
+ isGenerating?: boolean;
+ /**
+ * Specifies the maximum length allowed by the input.
+ * This will be checked both when the input element loses the
+ * focus and while typing within it. If the string entered does not
+ * comply the maximum length, the onBlur and onChange functions will be called
+ * with the current value and an internal error informing that the value
+ * length does not comply the specified range. If a valid length is
+ * reached, the error parameter of both events will not be defined.
+ */
+ maxLength?: number;
+ /**
+ * Specifies the minimum length allowed by the input.
+ * This will be checked both when the input element loses the
+ * focus and while typing within it. If the string entered does not
+ * comply the minimum length, the onBlur and onChange functions will be called
+ * with the current value and an internal error informing that the value
+ * length does not comply the specified range. If a valid length is
+ * reached, the error parameter of both events will not be defined.
+ */
+ minLength?: number;
+ /**
+ * Options to be shown on the dropdown under the input.
+ */
+ selectOptions?: SelectOption[];
+ /**
+ * This function will be called when the input element loses the focus.
+ * An object including the input value and the error (if the value
+ * entered is not valid) will be passed to this function. If there is no error,
+ * error will not be defined.
+ */
+ onBlur?: (val: { value: string; error?: string }) => void;
+ /**
+ * This function will be called when the user types within the input
+ * element of the component. An object including the current value and
+ * the error (if the value entered is not valid) will be passed to this
+ * function. If there is no error, error will not be defined.
+ */
+ onChange?: (val: { value: string; error?: string }) => void;
+ /**
+ * This function will be called when the user clicks on the button (submit or stop) or presses enter.
+ * The type parameter indicates whether it's a "submit" or "stop" event. For submit events, "value", "files" and "selectedOption" are provided.
+ */
+ onButtonClick?: (val: {
+ type: "submit" | "stop";
+ value?: string;
+ files?: File[];
+ selectedOption?: SelectOption;
+ }) => void;
+ /**
+ * Text to be put as placeholder of the input.
+ */
+ placeholder?: string;
+ /**
+ * Specifies the size of the component. The size will affect the width of the input.
+ */
+ size?: "small" | "medium" | "large" | "fillParent";
+ /**
+ * Value of the tabindex attribute.
+ */
+ tabIndex?: number;
+ /**
+ * Value of the input. If undefined, the component will be uncontrolled and the value will be managed internally by the component.
+ */
+ value?: string;
+};
+
+export default Props;
diff --git a/packages/lib/src/message-input/useVoiceTranscription.tsx b/packages/lib/src/message-input/useVoiceTranscription.tsx
new file mode 100644
index 000000000..32329e778
--- /dev/null
+++ b/packages/lib/src/message-input/useVoiceTranscription.tsx
@@ -0,0 +1,143 @@
+import { useCallback, useRef, useState } from "react";
+
+export type SpeechRecognitionAlternative = {
+ readonly transcript: string;
+ readonly confidence: number;
+};
+
+export type SpeechRecognitionResult = {
+ readonly length: number;
+ [index: number]: SpeechRecognitionAlternative;
+};
+
+export type SpeechRecognitionResultItem = {
+ readonly [index: number]: SpeechRecognitionAlternative;
+ readonly length: number;
+};
+
+export type SpeechRecognitionResultList = {
+ readonly [index: number]: SpeechRecognitionResultItem;
+ readonly length: number;
+};
+
+export type SpeechRecognitionEvent = Event & {
+ readonly results: SpeechRecognitionResultList;
+};
+
+export type SpeechRecognitionErrorEvent = Event & {
+ readonly error: string;
+};
+
+export type ISpeechRecognition = EventTarget & {
+ lang: string;
+ continuous: boolean;
+ interimResults: boolean;
+ onresult: ((event: SpeechRecognitionEvent) => void) | null;
+ onerror: ((event: SpeechRecognitionErrorEvent) => void) | null;
+ onend: (() => void) | null;
+ start(): void;
+ stop(): void;
+};
+
+type ISpeechRecognitionConstructor = {
+ new (): ISpeechRecognition;
+};
+
+export type WindowWithSpeechRecognition = Window & {
+ SpeechRecognition?: ISpeechRecognitionConstructor;
+ webkitSpeechRecognition?: ISpeechRecognitionConstructor;
+};
+
+type UseVoiceTranscriptionProps = {
+ lang: string;
+ continuous?: boolean;
+ interimResults?: boolean;
+ onError?: (error: string) => void;
+};
+
+type UseVoiceTranscriptionReturn = {
+ transcript: string;
+ isRecording: boolean;
+ isSupported: boolean;
+ startRecording: () => void;
+ stopRecording: () => void;
+ resetTranscript: () => void;
+ error?: string;
+};
+
+export const useVoiceTranscription = ({
+ lang,
+ continuous = true,
+ interimResults = true,
+ onError,
+}: UseVoiceTranscriptionProps): UseVoiceTranscriptionReturn => {
+ const [transcript, setTranscript] = useState("");
+ const [isRecording, setIsRecording] = useState(false);
+ const [error, setError] = useState();
+ const recognitionRef = useRef(null);
+ const resetTranscript = useCallback(() => setTranscript(""), []);
+
+ const windowWithSpeech =
+ typeof window !== "undefined" ? (window as unknown as WindowWithSpeechRecognition) : undefined;
+
+ const isSupported = !!(windowWithSpeech?.SpeechRecognition ?? windowWithSpeech?.webkitSpeechRecognition);
+
+ const startRecording = useCallback(() => {
+ if (!isSupported || recognitionRef.current || !windowWithSpeech) return;
+
+ const SpeechRecognitionConstructor = windowWithSpeech.SpeechRecognition ?? windowWithSpeech.webkitSpeechRecognition;
+
+ if (!SpeechRecognitionConstructor) return;
+
+ const recognition = new SpeechRecognitionConstructor();
+ recognition.lang = lang;
+ recognition.continuous = continuous;
+ recognition.interimResults = interimResults;
+
+ recognition.onresult = (event: SpeechRecognitionEvent) => {
+ const results: SpeechRecognitionResultItem[] = [];
+ for (let i = 0; i < event.results.length; i++) {
+ const result = event.results[i];
+ if (result) {
+ results.push(result);
+ }
+ }
+
+ const fullTranscript = results
+ .map((result) => {
+ const best = result[0];
+ return best && best.confidence >= 0.5 ? best.transcript : "";
+ })
+ .join(" ")
+ .trim();
+ setTranscript(fullTranscript);
+ };
+
+ recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
+ setError(event.error);
+ onError?.(event.error);
+
+ resetTranscript();
+ setIsRecording(false);
+ recognitionRef.current = null;
+ };
+
+ recognition.onend = () => {
+ setIsRecording(false);
+ resetTranscript();
+ recognitionRef.current = null;
+ };
+
+ recognition.start();
+ recognitionRef.current = recognition;
+ setIsRecording(true);
+ }, [isSupported, lang, continuous, interimResults, windowWithSpeech]);
+
+ const stopRecording = useCallback(() => {
+ if (recognitionRef.current) {
+ recognitionRef.current.stop();
+ }
+ }, []);
+
+ return { transcript, isRecording, isSupported, error, startRecording, stopRecording, resetTranscript };
+};
diff --git a/packages/lib/src/message-input/utils.ts b/packages/lib/src/message-input/utils.ts
new file mode 100644
index 000000000..7f2e504bf
--- /dev/null
+++ b/packages/lib/src/message-input/utils.ts
@@ -0,0 +1,50 @@
+import { css } from "@emotion/react";
+import { SelectOption } from "./types";
+
+export const getSelectedOption = (selectOptions: SelectOption[]) =>
+ selectOptions.find((option) => option.selected === true);
+
+export const getFilePreview = (file: File): string => {
+ if (file.type.includes("video")) return "filled_movie";
+ else if (file.type.includes("audio")) return "music_video";
+ else if (file.type.includes("image")) return "image";
+ else return "description";
+};
+
+export const inputStylesByStatePromptInput = (disabled: boolean, error: boolean, focus?: boolean) => css`
+ background-color: ${disabled
+ ? `var(--color-bg-neutral-lighter)`
+ : ` background-color: var(--color-bg-neutral-lightest);
+`};
+
+ border-radius: var(--border-radius-l);
+ border-width: ${!disabled && error ? "var(--border-width-m)" : "var(--border-width-s)"};
+ border-style: var(--border-style-default);
+ border-color: ${disabled
+ ? "var(--border-color-neutral-strong)"
+ : error
+ ? "var(--border-color-error-medium)"
+ : "var(--border-color-neutral-lighter)"};
+
+ ${!disabled
+ ? `
+ &:hover {
+ border-color: ${error ? "var(--border-color-error-strong)" : "var(--border-color-primary-strong)"};
+ }
+
+ &:focus {
+ outline-offset: -2px;
+ outline: var(--border-width-m) var(--border-style-default) var(--border-color-secondary-medium);
+ border-color: transparent;
+ }
+ `
+ : "cursor: not-allowed;"};
+
+ ${focus && !disabled
+ ? `
+ outline-offset: -2px;
+ outline: var(--border-width-m) var(--border-style-default) var(--border-color-secondary-medium);
+ border-color: transparent;
+ `
+ : ""};
+`;