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..561e2d67b --- /dev/null +++ b/apps/website/screens/components/message-input/code/MessageInputCodePage.tsx @@ -0,0 +1,259 @@ +import { DxcTable, DxcFlex } from "@dxc-technology/halstack-react"; +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 fileDataTypeString = `{ + label: string; + file: File; +};`; +const callbackFileTypeString = "(files: FileData[]) => void"; +const filesTypeString = `FileData[] | []`; +const selectOptionsTypeString = `{ + label?: string; + icon?: string | SVG; + value: string; + onSelect: () => void; + selected?: boolean; +}[]`; +const onButtonClickTypeString = `(val: { + type: "submit" | "stop"; + value?: string; + files?: FileData[]; + selectedOption?: SelectOption; + }) => void; +`; + +const sections = [ + { + title: "Props", + content: ( + + + + Name + Type + Description + Default + + + + + allowRecording + + boolean + + If true, the voice recording button will be shown. + + false + + + + callbackFile + + {callbackFileTypeString} +

+ being FileData an object with the following properties: +

+ {fileDataTypeString} + + This function will be called when the selection of top items changes. + - + + + 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 + + {filesTypeString} + + 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. + - + + + onBlur + + {"(val: { value: string; error?: string }) => void"} + + + 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. + + - + + + onChange + + {"(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. + + - + + + placeholder + + string + + Text to be put as placeholder of the input. + - + + + size + + {"'small' | 'medium' | 'large' | 'fillParent'"} + + 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..37a3292d9 --- /dev/null +++ b/apps/website/screens/components/message-input/code/examples/advanced.tsx @@ -0,0 +1,102 @@ +import { DxcMessageInput, DxcInset, DxcFlex, DxcButton } from "@dxc-technology/halstack-react"; +import { useState } from "react"; + +const code = `() => { + const [value, setValue] = useState(""); + const [files, setFiles] = useState([ + { label: "document.pdf", icon: "insert_drive_file" }, + { label: "image.png", icon: "image" } + ]); + 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..ab27256fe --- /dev/null +++ b/apps/website/screens/components/message-input/overview/MessageInputOverviewPage.tsx @@ -0,0 +1,324 @@ +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: ( + <> + Message input anatomy + + + 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. + +
+ An example of our message input inside a conversational interface. +
+ + ), + 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. + + +
+ Generating state of the message input +
+ + ), + }, + { + 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. + + + Show the voice button only when voice input is functional: if voice capability is + conditional, perform capability detection before enabling the voice button property. A microphone + button that does nothing erodes trust and usability. + + + + ), + }, + ], + }, + ], + }, +]; + +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/packages/lib/src/common/variables.ts b/packages/lib/src/common/variables.ts index f5a58b378..f1bc14d30 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..a6570b9bd --- /dev/null +++ b/packages/lib/src/message-input/MessageInput.stories.tsx @@ -0,0 +1,119 @@ +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 = [ + { label: "document.pdf" }, + { label: "document.pdf" }, + { label: "document.pdf" }, + { label: "document.pdf" }, +]; + +const MessageInput = () => ( + <> + + <ExampleContainer> + <Title title="Small" level={4} /> + <DxcMessageInput selectOptions={selectOptionsOptions} size="small" placeholder="Ask me anything..." /> + </ExampleContainer> + <ExampleContainer> + <Title title="Medium (default)" level={4} /> + <DxcMessageInput selectOptions={selectOptionsOptions} size="medium" placeholder="Ask me anything..." /> + </ExampleContainer> + <ExampleContainer> + <Title title="Large" level={4} /> + <DxcMessageInput selectOptions={selectOptionsOptions} size="large" placeholder="Ask me anything..." /> + </ExampleContainer> + <ExampleContainer> + <Title title="Fill Parent" level={4} /> + <DxcMessageInput selectOptions={selectOptionsOptions} size="fillParent" placeholder="Ask me anything..." /> + </ExampleContainer> + + <ExampleContainer> + <Title title="With Voice Input" level={2} /> + <DxcMessageInput placeholder="Ask me anything with voice..." allowRecording /> + </ExampleContainer> + <ExampleContainer> + <Title title="With Model List" level={2} /> + <DxcMessageInput placeholder="Select a model and ask..." selectOptions={selectOptionsOptions} /> + </ExampleContainer> + <ExampleContainer> + <Title title="With Files" level={2} /> + <DxcMessageInput + placeholder="Full featured input..." + allowRecording + selectOptions={selectOptionsOptions} + files={files} + callbackFile={() => console.log("added file")} + /> + </ExampleContainer> + + {/* ================== STATES ================== */} + <Title title="States" theme="light" level={2} /> + <ExampleContainer> + <Title title="Default" level={4} /> + <DxcMessageInput placeholder="Ask me anything..." /> + </ExampleContainer> + <ExampleContainer pseudoState="pseudo-hover"> + <Title title="Hover" level={4} /> + <DxcMessageInput placeholder="Ask me anything..." /> + </ExampleContainer> + <ExampleContainer pseudoState="pseudo-focus"> + <Title title="Focused" level={4} /> + <DxcMessageInput placeholder="Ask me anything..." /> + </ExampleContainer> + <ExampleContainer pseudoState={["pseudo-focus", "pseudo-hover"]}> + <Title title="Active" level={4} /> + <DxcMessageInput placeholder="Ask me anything..." /> + </ExampleContainer> + <ExampleContainer> + <Title title="Disabled" level={4} /> + <DxcMessageInput placeholder="This input is disabled..." disabled /> + </ExampleContainer> + <ExampleContainer> + <Title title="Is Generating" level={4} /> + <DxcMessageInput placeholder="AI is generating response..." isGenerating /> + </ExampleContainer> + <ExampleContainer> + <Title title="Error" level={4} /> + <DxcMessageInput placeholder="Ask me anything..." error="Please enter a valid message" /> + </ExampleContainer> + <ExampleContainer pseudoState="pseudo-hover"> + <Title title="Hover error" level={4} /> + <DxcMessageInput placeholder="Ask me anything..." error="Error Message" /> + </ExampleContainer> + + <Title title="Validation" theme="light" level={2} /> + <ExampleContainer> + <Title title="Min Length (15 characters)" level={4} /> + <DxcMessageInput placeholder="Ask me anything..." minLength={15} defaultValue="Hello" /> + </ExampleContainer> + <ExampleContainer> + <Title title="Max Length (50 characters)" level={4} /> + <DxcMessageInput + placeholder="Ask me anything..." + maxLength={50} + defaultValue="This is a test message to demonstrate the maximum length validation feature" + /> + </ExampleContainer> + </> +); + +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..c3b890aa3 --- /dev/null +++ b/packages/lib/src/message-input/MessageInput.test.tsx @@ -0,0 +1,479 @@ +import "@testing-library/jest-dom"; +import { act, 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(), +})); + +describe("Message Input component tests", () => { + test("Message Input renders correctly", () => { + const { getByPlaceholderText } = render(<DxcMessageInput placeholder="Ask me anything..." />); + const input = getByPlaceholderText("Ask me anything..."); + expect(input).toBeInTheDocument(); + }); + + test("renders with default value", () => { + const { getByDisplayValue } = render(<DxcMessageInput defaultValue="Hello World" />); + expect(getByDisplayValue("Hello World")).toBeInTheDocument(); + }); + + test("renders with error message", () => { + const { getByText } = render(<DxcMessageInput error="This is an error" />); + expect(getByText("This is an error")).toBeInTheDocument(); + }); + + test("calls onChange when user types", () => { + const onChange = jest.fn(); + const { getByRole } = render(<DxcMessageInput onChange={onChange} />); + 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(<DxcMessageInput onBlur={onBlur} />); + 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(<DxcMessageInput onButtonClick={onButtonClick} />); + 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(<DxcMessageInput onButtonClick={onButtonClick} />); + 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(<DxcMessageInput disabled onButtonClick={onButtonClick} />); + 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(<DxcMessageInput isGenerating onButtonClick={onButtonClick} />); + 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(<DxcMessageInput onButtonClick={onButtonClick} />); + 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(<DxcMessageInput disabled />); + const input = getByRole("textbox"); + + expect(input).toBeDisabled(); + }); + + test("disables input when isGenerating is true", () => { + const { getByRole } = render(<DxcMessageInput isGenerating />); + const input = getByRole("textbox"); + + expect(input).toBeDisabled(); + }); + + test("shows stop button when isGenerating is true", () => { + const onButtonClick = jest.fn(); + const { getByLabelText } = render(<DxcMessageInput isGenerating onButtonClick={onButtonClick} />); + 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(<DxcMessageInput isGenerating onButtonClick={onButtonClick} />); + const stopButton = getByLabelText("Stop request"); + + userEvent.click(stopButton); + + expect(onButtonClick).toHaveBeenCalledWith({ type: "stop" }); + expect(onButtonClick).toHaveBeenCalledTimes(1); + }); + + test("validates minLength and calls onChange with error", () => { + const onChange = jest.fn(); + // TO BE DONE: Remove maxLength when formFields message errors is updated to support minLength and maxLength separately. + const { getByRole } = render(<DxcMessageInput minLength={5} maxLength={200} onChange={onChange} />); + const input = getByRole("textbox"); + + userEvent.type(input, "Hi"); + + // Verify error is present in the call + expect(onChange).toHaveBeenCalled(); + const calls = onChange.mock.calls; + const lastCall = calls[calls.length - 1] as [{ value: string; error?: string }]; + expect(lastCall[0].value).toBe("Hi"); + expect(lastCall[0].error).toBeDefined(); + }); + + test("validates maxLength attribute is set", () => { + const { getByRole } = render(<DxcMessageInput maxLength={10} />); + const input = getByRole("textbox"); + + expect(input).toHaveAttribute("maxLength", "10"); + }); + + test("validates minLength on blur", () => { + const onBlur = jest.fn(); + // TO BE DONE: Remove maxLength when formFields message errors is updated to support minLength and maxLength separately. + + const { getByRole } = render(<DxcMessageInput minLength={5} maxLength={200} onBlur={onBlur} />); + const input = getByRole("textbox"); + + userEvent.click(input); + userEvent.type(input, "Hi"); + userEvent.tab(); + + // Verify error is present in the call + expect(onBlur).toHaveBeenCalled(); + const calls = onBlur.mock.calls; + const lastCall = calls[calls.length - 1] as [{ value: string; error?: string }]; + expect(lastCall[0].value).toBe("Hi"); + expect(lastCall[0].error).toBeDefined(); + }); + + test("works as controlled component", () => { + const onChange = jest.fn(); + const { getByRole, rerender } = render(<DxcMessageInput value="Initial" onChange={onChange} />); + const input = getByRole("textbox"); + + expect(input).toHaveValue("Initial"); + + userEvent.type(input, " text"); + + rerender(<DxcMessageInput value="Initial text" onChange={onChange} />); + expect(input).toHaveValue("Initial text"); + }); + + test("renders file upload dropdown when files prop is provided", () => { + const { getByRole } = render(<DxcMessageInput files={[]} callbackFile={() => 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(<DxcMessageInput selectOptions={selectOptions} />); + 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(<DxcMessageInput selectOptions={selectOptions} />); + 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(<DxcMessageInput disabled error="This is an error" />); + expect(queryByText("This is an error")).not.toBeInTheDocument(); + }); + + test("does not call onButtonClick when disabled", () => { + const onButtonClick = jest.fn(); + const { getByLabelText } = render(<DxcMessageInput disabled onButtonClick={onButtonClick} />); + 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(<DxcMessageInput isGenerating onButtonClick={onButtonClick} />); + + // The button should be the stop button when isGenerating + const button = getByLabelText("Stop request"); + expect(button).toBeInTheDocument(); + }); + + test("handles file selection", () => { + const { getByRole } = render(<DxcMessageInput files={[]} callbackFile={() => 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(<DxcMessageInput files={[]} callbackFile={callbackFile} />); + + 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(<DxcMessageInput onButtonClick={onButtonClick} />); + const submitButton = getByLabelText("Send message"); + + userEvent.click(submitButton); + expect(onButtonClick).toHaveBeenCalledWith({ type: "submit", value: "" }); + expect(onButtonClick).toHaveBeenCalledTimes(1); + }); + + test("validates minLength correctly when value is exactly minLength", () => { + const onChange = jest.fn(); + const { getByRole } = render(<DxcMessageInput minLength={5} maxLength={100} onChange={onChange} />); + const input = getByRole("textbox"); + + userEvent.type(input, "Hell"); + expect(onChange).toHaveBeenLastCalledWith({ value: "Hell", error: "Min length 5, max length 100." }); + + userEvent.type(input, "o"); + expect(onChange).toHaveBeenLastCalledWith({ value: "Hello" }); + }); + + test("works with file uploads", () => { + const callbackFile = jest.fn(); + const { container } = render(<DxcMessageInput files={[]} callbackFile={callbackFile} />); + + 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(); + }); +}); + +// 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("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..adc43d06a --- /dev/null +++ b/packages/lib/src/message-input/MessageInput.tsx @@ -0,0 +1,415 @@ +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, isLengthOutOfRange } 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"; + +const sizes = { + small: "240px", + medium: "360px", + large: "480px", + fillParent: "100%", +} as const; + +const MessageInputContainer = styled.div<{ size: PromptInputPropsType["size"] }>` + width: ${({ size = "medium" }) => sizes[size]}; + display: flex; + flex-direction: column; + gap: var(--spacing-gap-xs); +`; + +const MessageInput = styled.div<{ + disabled: Required<PromptInputPropsType>["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); + background-color: var(--color-bg-neutral-lightest); + 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` + min-height: 32px; + 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 HighlightedText = styled.span` + color: var(--color-fg-neutral-strong); +`; + +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 locale = languageContext.locale ?? "en-US"; + const inputId = `input-${useId()}`; + const inputRef = useRef<HTMLTextAreaElement | null>(null); + const overlayRef = useRef<HTMLDivElement | null>(null); + const [innerValue, setInnerValue] = useState(defaultValue); + const [isFocused, setIsFocused] = useState(false); + const fileInputRef = useRef<HTMLInputElement | null>(null); + const { + transcript, + isRecording, + startRecording, + stopRecording, + resetTranscript, + error: transcriptError, + } = useVoiceTranscription({ + lang: locale, + }); + const dropdownOptions = [{ label: languageContext.labels.messageInput.attachFileButtonTitle, value: "fileorphoto" }]; + + const getLengthError = (val: string) => + isLengthOutOfRange(val, minLength, maxLength) + ? languageContext.labels.formFields.lengthErrorMessage?.(minLength, maxLength) + : undefined; + + const getError = (val: string) => transcriptError ?? getLengthError(val); + + const changeValue = (newValue: string) => { + if (value == null) setInnerValue(newValue); + onChange?.({ value: newValue, error: getError(newValue) }); + }; + + const handleInputContainerOnClick = () => inputRef.current?.focus(); + + const handleInputContainerOnMouseDown = (event: MouseEvent<HTMLDivElement>) => { + if (document.activeElement === inputRef.current) event.preventDefault(); + }; + + const handleInputOnChange = (event: ChangeEvent<HTMLTextAreaElement>) => { + changeValue(event.target.value); + }; + + const handleInputOnFocus = () => setIsFocused(true); + + const handleInputOnBlur = (event: FocusEvent<HTMLTextAreaElement>) => { + setIsFocused(false); + + onBlur?.({ value: event.target.value, error: getError(event.target.value) }); + }; + + const handleFileSelect = () => fileInputRef.current?.click(); + + const handleFileInputOnChange = (event: ChangeEvent<HTMLInputElement>) => { + const filesArray = Array.from(event.target.files ?? []); + const selectedFiles = filesArray.map((file) => ({ + label: file.name, + icon: getFilePreview(file), + file: file, + })); + 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 scroll synchronization + useEffect(() => { + if (!transcript) return; + + const combined = baseTextRef.current ? `${baseTextRef.current} ${transcript}` : transcript; + changeValue(combined); + + if (inputRef.current) { + inputRef.current.style.height = "auto"; + inputRef.current.style.height = `${inputRef.current.scrollHeight}px`; + if (overlayRef.current) overlayRef.current.scrollTop = inputRef.current.scrollTop; + } + }, [transcript, changeValue]); + + 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<HTMLTextAreaElement>) => { + if (!disabled && !isGenerating && event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + handleSubmit(); + } + }; + + return ( + <MessageInputContainer size={size}> + <MessageInput + disabled={disabled} + error={!!error} + focus={isFocused} + hasFiles={files && typeof callbackFile === "function"} + onClick={handleInputContainerOnClick} + onMouseDown={handleInputContainerOnMouseDown} + > + {files && typeof callbackFile === "function" && ( + <FilesContainer> + <DxcDropdown + options={dropdownOptions} + onSelectOption={handleFileSelect} + icon="add" + disabled={isGenerating || disabled || isRecording} + caretHidden + /> + <input ref={fileInputRef} type="file" hidden multiple onChange={handleFileInputOnChange} /> + {(files ?? []).map((item, index) => ( + <DxcChip + key={index} + label={item.label} + mode="dismissible" + prefix={item.icon ?? "description"} + onClick={() => removeItem(index)} + /> + ))} + </FilesContainer> + )} + <InputWrapper> + <MessageArea + aria-label={languageContext.labels.messageInput.inputAriaLabel} + aria-errormessage={error ? `error-${inputId}` : undefined} + aria-invalid={!!error} + disabled={isGenerating || disabled} + id={inputId} + isRecording={isRecording} + onBlur={handleInputOnBlur} + onChange={handleInputOnChange} + onFocus={handleInputOnFocus} + onKeyDown={handleInputOnKeyDown} + onMouseDown={(event) => { + event.stopPropagation(); + }} + placeholder={placeholder} + ref={inputRef} + tabIndex={tabIndex} + maxLength={maxLength} + minLength={minLength} + value={value ?? innerValue} + /> + {isRecording && ( + <TranscriptOverlay ref={overlayRef}> + {baseTextRef.current && <span>{baseTextRef.current}</span>} + {transcript && ( + <> + {transcript.length > 2 ? ( + <> + <span>{transcript.slice(0, -2)}</span> + <HighlightedText>{transcript.slice(-2)} ...</HighlightedText> + </> + ) : ( + <HighlightedText>{transcript}</HighlightedText> + )} + </> + )} + </TranscriptOverlay> + )} + </InputWrapper> + <DxcFlex justifyContent={selectOptions ? "space-between" : "flex-end"} alignItems="center"> + {selectOptions && ( + <DxcContainer width="35%" maxWidth="240px"> + <DxcSelect + size="fillParent" + options={selectOptions.map((option) => ({ 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); + }} + /> + </DxcContainer> + )} + + <DxcFlex gap="var(--spacing-gap-xs)"> + {allowRecording && ( + <DxcButton + icon={isRecording ? "filled_pause" : "mic"} + size={{ height: "medium" }} + mode="tertiary" + disabled={isGenerating || disabled} + onClick={toggleVoiceRecognition} + title={ + isRecording + ? languageContext.labels.messageInput.stopRecordingButtonTitle + : languageContext.labels.messageInput.recordAudioButtonTitle + } + aria-label={ + isRecording + ? languageContext.labels.messageInput.stopRecordingButtonTitle + : languageContext.labels.messageInput.recordAudioButtonTitle + } + /> + )} + + <DxcButton + icon={!isGenerating ? "send" : "filled_stop"} + size={{ height: "medium" }} + disabled={disabled || isRecording} + onClick={!isGenerating ? handleSubmit : handleStop} + title={ + !isGenerating + ? languageContext.labels.messageInput.sendButtonTitle + : languageContext.labels.messageInput.stopButtonTitle + } + aria-label={ + !isGenerating + ? languageContext.labels.messageInput.sendButtonTitle + : languageContext.labels.messageInput.stopButtonTitle + } + /> + </DxcFlex> + </DxcFlex> + </MessageInput> + {!disabled && typeof error === "string" && <ErrorMessage error={error} id={`error-${inputId}`} />} + </MessageInputContainer> + ); +}; + +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..73003907f --- /dev/null +++ b/packages/lib/src/message-input/types.ts @@ -0,0 +1,121 @@ +import { SVG } from "../common/utils"; + +export type FileData = { + /** + * The chip label. + */ + label: string; + /** + * The chip icon. It can be a string representing the icon name. + */ + icon?: string; + /** + * The file object. + */ + file: File; +}; + +export type SelectOption = { + label?: string; + icon?: string | SVG; + value: string; + onSelect: (value: string) => void; + selected?: boolean; +}; + +type CommonProps = { + /** + * If true, the voice recording button will be shown. + */ + allowRecording?: boolean; + /** + * 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; + /** + * 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?: FileData[]; + 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; +}; + +type Props = CommonProps & + ({ files: FileData[]; callbackFile: (files: FileData[]) => void } | { files?: undefined; callbackFile?: undefined }); + +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<string>(); + const recognitionRef = useRef<ISpeechRecognition | null>(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..b2eda26b3 --- /dev/null +++ b/packages/lib/src/message-input/utils.ts @@ -0,0 +1,51 @@ +import { css } from "@emotion/react"; +import { SelectOption } from "./types"; + +// TO BE DONE: IMPLEMENT SEPARATELY minLenght & maxLength +export const isLengthOutOfRange = (value: string, minLength?: number, maxLength?: number) => + value !== "" && minLength && maxLength && (value.length < minLength || value.length > maxLength); + +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-lightest)` : `transparent`}; + + border-radius: var(--border-radius-l); + + border: ${!disabled && error ? "var(--border-width-m)" : "var(--border-width-s)"} var(--border-style-default) + ${(() => { + if (disabled) return "var(--border-color-neutral-lighter)"; + else if (error) return "var(--border-color-error-medium)"; + else return "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; + ` + : ""}; +`;