From 601533566e38ebdf3159deaae2f808c2da93b97d Mon Sep 17 00:00:00 2001 From: Red Adaya Date: Sun, 20 Oct 2024 19:41:46 +0800 Subject: [PATCH] ChatInput component --- frontend/app/element/chatinput.less | 66 +++++++ frontend/app/element/chatinput.stories.tsx | 104 ++++++++++ frontend/app/element/chatinput.tsx | 128 +++++++++++++ frontend/app/element/emojipalette.stories.tsx | 3 - frontend/app/element/emojipalette.tsx | 22 ++- frontend/app/element/input.stories.tsx | 15 -- frontend/app/element/input.tsx | 48 ++--- frontend/app/element/inputmultiline.less | 10 - .../app/element/inputmultiline.stories.tsx | 52 ----- frontend/app/element/intputmultiline.tsx | 178 ------------------ 10 files changed, 324 insertions(+), 302 deletions(-) create mode 100644 frontend/app/element/chatinput.less create mode 100644 frontend/app/element/chatinput.stories.tsx create mode 100644 frontend/app/element/chatinput.tsx delete mode 100644 frontend/app/element/inputmultiline.less delete mode 100644 frontend/app/element/inputmultiline.stories.tsx delete mode 100644 frontend/app/element/intputmultiline.tsx diff --git a/frontend/app/element/chatinput.less b/frontend/app/element/chatinput.less new file mode 100644 index 000000000..630b66f15 --- /dev/null +++ b/frontend/app/element/chatinput.less @@ -0,0 +1,66 @@ +.chat-group { + display: flex; + align-items: stretch; /* Make both textarea and palette stretch equally */ + border-radius: 6px; + position: relative; + width: 100%; + border: 2px solid var(--form-element-border-color); + background: var(--form-element-bg-color); + padding: 5px; + gap: 5px; + + /* Focus style */ + &.focused { + border-color: var(--form-element-primary-color); + } + + /* Error state */ + &.error { + border-color: var(--form-element-error-color); + } + + /* Disabled state */ + &.disabled { + opacity: 0.75; + } + + &:hover { + cursor: text; + } + + .input-left-element, + .input-right-element { + padding: 0 5px; + display: flex; + align-items: center; + justify-content: center; + } + + /* Textarea */ + .chat-textarea { + flex-grow: 1; /* Textarea should take up remaining space */ + margin: 0; + padding: 5px; + border: none; + box-shadow: none; + background-color: transparent; + resize: none; + overflow-y: auto; /* Only scroll when the max height is reached */ + line-height: 1.5; + color: var(--form-element-text-color); + min-height: 24px; + + &:focus-visible { + outline: none; + } + } + + /* Emoji palette container, stays at the bottom */ + .emoji-palette { + display: flex; + align-items: flex-end; /* Aligns the emoji palette button to the bottom */ + justify-content: center; + height: 100%; /* Ensure full height */ + min-width: 40px; /* Set a minimum width for the emoji button area */ + } +} diff --git a/frontend/app/element/chatinput.stories.tsx b/frontend/app/element/chatinput.stories.tsx new file mode 100644 index 000000000..dc2887df8 --- /dev/null +++ b/frontend/app/element/chatinput.stories.tsx @@ -0,0 +1,104 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { ChatInput } from "./chatinput"; + +const meta: Meta = { + title: "Elements/ChatInput", + component: ChatInput, + argTypes: { + value: { + description: "The value of the textarea.", + control: "text", + }, + placeholder: { + description: "The placeholder text for the textarea.", + control: "text", + defaultValue: "Type a message...", + }, + maxRows: { + description: "Maximum number of rows the textarea can expand to.", + control: "number", + defaultValue: 5, + }, + rows: { + description: "Initial number of rows for the textarea.", + control: "number", + defaultValue: 1, + }, + maxLength: { + description: "The maximum number of characters allowed.", + control: "number", + defaultValue: 200, + }, + autoFocus: { + description: "Autofocus the input when the component mounts.", + control: "boolean", + defaultValue: false, + }, + disabled: { + description: "Disables the textarea if set to true.", + control: "boolean", + defaultValue: false, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +// Default ChatInput Story +export const DefaultChatInput: Story = { + render: (args) => { + const [message, setMessage] = useState(""); + + return ( +
+ +
+ ); + }, + args: { + placeholder: "Type your message...", + rows: 1, + maxRows: 5, + }, +}; + +// ChatInput with long text +export const ChatInputWithLongText: Story = { + render: (args) => { + const [message, setMessage] = useState("This is a long message that will expand the textarea."); + + return ( +
+ +
+ ); + }, + args: { + placeholder: "Type a long message...", + rows: 2, + maxRows: 10, + }, +}; diff --git a/frontend/app/element/chatinput.tsx b/frontend/app/element/chatinput.tsx new file mode 100644 index 000000000..ffc8addd8 --- /dev/null +++ b/frontend/app/element/chatinput.tsx @@ -0,0 +1,128 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import clsx from "clsx"; +import React, { useEffect, useRef, useState } from "react"; +import { EmojiPalette } from "./emojipalette"; + +import "./chatinput.less"; + +interface ChatInputProps { + value?: string; + className?: string; + onChange?: (value: string) => void; + onKeyDown?: (event: React.KeyboardEvent) => void; + onFocus?: () => void; + onBlur?: () => void; + placeholder?: string; + defaultValue?: string; + maxLength?: number; + autoFocus?: boolean; + disabled?: boolean; + rows?: number; + maxRows?: number; + inputRef?: React.MutableRefObject; + manageFocus?: (isFocused: boolean) => void; +} + +const ChatInput = ({ + value, + className, + onChange, + onKeyDown, + onFocus, + onBlur, + placeholder, + defaultValue = "", + maxLength, + autoFocus, + disabled, + rows = 1, + maxRows = 5, + inputRef, + manageFocus, +}: ChatInputProps) => { + const textareaRef = inputRef || useRef(null); + const actionWrapperRef = useRef(null); + const [internalValue, setInternalValue] = useState(defaultValue); + const [lineHeight, setLineHeight] = useState(24); // Default line height fallback of 24px + + const handleInputChange = (e: React.ChangeEvent) => { + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; // Reset height + const maxHeight = maxRows * lineHeight; // Calculate max height + const newHeight = Math.min(textareaRef.current.scrollHeight, maxHeight); + textareaRef.current.style.height = `${newHeight}px`; // Set height dynamically + } + + setInternalValue(e.target.value); + onChange && onChange(e.target.value); + }; + + const handleFocus = () => { + manageFocus?.(true); + onFocus?.(); + }; + + const handleBlur = () => { + manageFocus?.(false); + onBlur?.(); + }; + + useEffect(() => { + if (textareaRef.current) { + const computedStyle = window.getComputedStyle(textareaRef.current); + let lineHeightValue = computedStyle.lineHeight; + + if (lineHeightValue === "normal") { + const fontSize = parseFloat(computedStyle.fontSize); + lineHeightValue = `${fontSize * 1.2}px`; // Fallback to 1.2 ratio of font size + } + + const detectedLineHeight = parseFloat(lineHeightValue); + setLineHeight(detectedLineHeight || 24); // Fallback if detection fails + } + }, [textareaRef]); + + useEffect(() => { + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; + const maxHeight = maxRows * lineHeight; + const newHeight = Math.min(textareaRef.current.scrollHeight, maxHeight); + textareaRef.current.style.height = `${newHeight}px`; + actionWrapperRef.current.style.height = `${newHeight}px`; + } + }, [value, maxRows, lineHeight]); + + const inputValue = value ?? internalValue; + + return ( +
+