ChatInput component

This commit is contained in:
Red Adaya 2024-10-20 19:41:46 +08:00
parent 738502b95c
commit 601533566e
10 changed files with 324 additions and 302 deletions

View File

@ -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 */
}
}

View File

@ -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<typeof ChatInput> = {
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<typeof ChatInput>;
// Default ChatInput Story
export const DefaultChatInput: Story = {
render: (args) => {
const [message, setMessage] = useState("");
return (
<div
style={{
width: "100%",
height: "600px",
padding: "20px",
display: "flex",
alignItems: "flex-end",
justifyContent: "center",
}}
>
<ChatInput {...args} value={message} onChange={setMessage} />
</div>
);
},
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 (
<div
style={{
width: "100%",
height: "600px",
padding: "20px",
display: "flex",
alignItems: "flex-end",
justifyContent: "center",
}}
>
<ChatInput {...args} value={message} onChange={setMessage} />
</div>
);
},
args: {
placeholder: "Type a long message...",
rows: 2,
maxRows: 10,
},
};

View File

@ -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<HTMLTextAreaElement>) => void;
onFocus?: () => void;
onBlur?: () => void;
placeholder?: string;
defaultValue?: string;
maxLength?: number;
autoFocus?: boolean;
disabled?: boolean;
rows?: number;
maxRows?: number;
inputRef?: React.MutableRefObject<HTMLTextAreaElement>;
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<HTMLTextAreaElement>(null);
const actionWrapperRef = useRef<HTMLDivElement>(null);
const [internalValue, setInternalValue] = useState(defaultValue);
const [lineHeight, setLineHeight] = useState(24); // Default line height fallback of 24px
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
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 (
<div className={clsx("chat-group", className)}>
<textarea
className="chat-textarea"
ref={textareaRef}
value={inputValue}
onChange={handleInputChange}
onKeyDown={onKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={placeholder}
maxLength={maxLength}
autoFocus={autoFocus}
disabled={disabled}
rows={rows}
style={{
overflowY:
textareaRef.current && textareaRef.current.scrollHeight > maxRows * lineHeight
? "auto"
: "hidden",
}}
/>
<div ref={actionWrapperRef}>
<EmojiPalette placement="top-end" />
</div>
</div>
);
};
export { ChatInput };

View File

@ -11,9 +11,6 @@ const meta: Meta<typeof EmojiPalette> = {
className: "custom-emoji-palette-class",
},
argTypes: {
scopeRef: {
description: "Reference to the outer container element for positioning",
},
className: {
description: "Custom class for emoji palette styling",
},

View File

@ -1,19 +1,15 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { type Placement } from "@floating-ui/react";
import clsx from "clsx";
import React, { memo, useState } from "react";
import { memo, useState } from "react";
import { Button } from "./button";
import { Input } from "./input";
import { Input, InputGroup, InputLeftElement } from "./input";
import { Palette, PaletteButton, PaletteContent } from "./palette";
import "./emojiPalette.less";
interface EmojiPaletteProps {
scopeRef: React.RefObject<HTMLElement>;
className?: string;
}
const emojiList = [
// Smileys & Emotion
{ emoji: "😀", name: "grinning face" },
@ -217,9 +213,10 @@ const emojiList = [
interface EmojiPaletteProps {
className?: string;
placement?: Placement;
}
const EmojiPalette = memo(({ className }: EmojiPaletteProps) => {
const EmojiPalette = memo(({ className, placement }: EmojiPaletteProps) => {
const [searchTerm, setSearchTerm] = useState("");
const handleSearchChange = (val: string) => {
@ -230,12 +227,17 @@ const EmojiPalette = memo(({ className }: EmojiPaletteProps) => {
return (
<div className={clsx("emoji-palette", className)}>
<Palette>
<Palette placement={placement}>
<PaletteButton className="ghost grey">
<i className="fa-sharp fa-solid fa-face-smile"></i>
</PaletteButton>
<PaletteContent className="emoji-palette-content">
<Input placeholder="Search emojis..." value={searchTerm} onChange={handleSearchChange} />
<InputGroup>
<InputLeftElement>
<i className="fa-sharp fa-solid fa-magnifying-glass"></i>
</InputLeftElement>
<Input placeholder="Search emojis..." value={searchTerm} onChange={handleSearchChange} />
</InputGroup>
<div className="emoji-grid">
{filteredEmojis.length > 0 ? (
filteredEmojis.map((item, index) => (

View File

@ -70,18 +70,3 @@ export const InputWithLeftAndRightElement: Story = {
className: "custom-input-group-class",
},
};
export const InputMultiLine: Story = {
render: (args) => {
return (
<div style={{ padding: "20px", height: "400px", border: "2px solid black" }}>
<InputGroup>
<Input multiLine={true} placeholder="Enter your message" rows={5} />
</InputGroup>
</div>
);
},
args: {
className: "custom-input-group-class",
},
};

View File

@ -69,8 +69,6 @@ interface InputProps {
isNumber?: boolean;
inputRef?: React.MutableRefObject<any>;
manageFocus?: (isFocused: boolean) => void;
multiLine?: boolean;
rows?: number;
}
const Input = forwardRef<HTMLDivElement, InputProps>(
@ -92,8 +90,6 @@ const Input = forwardRef<HTMLDivElement, InputProps>(
isNumber,
inputRef,
manageFocus,
multiLine = false,
rows = 1,
}: InputProps,
ref
) => {
@ -138,36 +134,20 @@ const Input = forwardRef<HTMLDivElement, InputProps>(
{label}
</label>
)}
{multiLine ? (
<textarea
className={clsx("input-inner-textarea")}
ref={inputRef}
value={inputValue}
onChange={handleInputChange}
onKeyDown={onKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={placeholder}
maxLength={maxLength}
autoFocus={autoFocus}
disabled={disabled}
rows={rows}
/>
) : (
<input
className={clsx("input-inner-input")}
ref={inputRef}
value={inputValue}
onChange={handleInputChange}
onKeyDown={onKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={placeholder}
maxLength={maxLength}
autoFocus={autoFocus}
disabled={disabled}
/>
)}
<input
className={clsx("input-inner-input")}
ref={inputRef}
value={inputValue}
onChange={handleInputChange}
onKeyDown={onKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={placeholder}
maxLength={maxLength}
autoFocus={autoFocus}
disabled={disabled}
/>
</div>
</div>
);

View File

@ -1,10 +0,0 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
// Multi-Line Input Customization for textarea
textarea.input-inner-input {
overflow: hidden; // Hide native scrollbars, and let height expand automatically
min-height: 32px; // Minimum height of textarea
max-height: 150px; // Set a maximum height before scrolling is triggered
line-height: 1.5em; // Control line height for better readability
}

View File

@ -1,52 +0,0 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import type { Meta, StoryObj } from "@storybook/react";
import { InputMultiLine } from "./intputmultiline";
const meta: Meta<typeof InputMultiLine> = {
title: "Elements/InputMultiLine",
component: InputMultiLine,
args: {
label: "Message",
placeholder: "Type your message here...",
className: "custom-input-class",
},
argTypes: {
label: {
description: "Label for the input field",
},
placeholder: {
description: "Placeholder text for the input",
},
className: {
description: "Custom class for input styling",
},
decoration: {
description: "Input decorations for start or end positions",
},
},
};
export default meta;
type Story = StoryObj<typeof InputMultiLine>;
export const DefaultInput: Story = {
render: (args) => {
return <InputMultiLine {...args} />;
},
args: {
label: "Message",
placeholder: "Type your message...",
},
};
export const InputWithErrorState: Story = {
render: (args) => {
return <InputMultiLine {...args} required={true} />;
},
args: {
label: "Required Message",
placeholder: "This field is required...",
},
};

View File

@ -1,178 +0,0 @@
import { clsx } from "clsx";
import React, { forwardRef, useEffect, useRef, useState } from "react";
import "./inputmultiline.less";
interface InputDecorationProps {
startDecoration?: React.ReactNode;
endDecoration?: React.ReactNode;
}
interface InputMultiLineProps {
label?: string;
value?: string;
className?: string;
onChange?: (value: string) => void;
onKeyDown?: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onFocus?: () => void;
onBlur?: () => void;
placeholder?: string;
defaultValue?: string;
decoration?: InputDecorationProps;
required?: boolean;
maxLength?: number;
autoFocus?: boolean;
disabled?: boolean;
inputRef?: React.MutableRefObject<HTMLTextAreaElement>;
}
const InputMultiLine = forwardRef<HTMLDivElement, InputMultiLineProps>(
(
{
label,
value,
className,
onChange,
onKeyDown,
onFocus,
onBlur,
placeholder,
defaultValue = "",
decoration,
required,
maxLength,
autoFocus,
disabled,
inputRef,
}: InputMultiLineProps,
ref
) => {
const [focused, setFocused] = useState(false);
const [internalValue, setInternalValue] = useState(defaultValue);
const [error, setError] = useState(false);
const [hasContent, setHasContent] = useState(Boolean(value || defaultValue));
const internalInputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (value !== undefined) {
setFocused(Boolean(value));
}
}, [value]);
const handleComponentFocus = () => {
if (internalInputRef.current && !internalInputRef.current.contains(document.activeElement)) {
internalInputRef.current.focus();
}
};
const handleComponentBlur = () => {
if (internalInputRef.current?.contains(document.activeElement)) {
internalInputRef.current.blur();
}
};
const handleSetInputRef = (elem: HTMLTextAreaElement) => {
if (inputRef) {
inputRef.current = elem;
}
internalInputRef.current = elem;
};
const handleFocus = () => {
setFocused(true);
onFocus && onFocus();
};
const handleBlur = () => {
if (internalInputRef.current) {
const inputValue = internalInputRef.current.value;
if (required && !inputValue) {
setError(true);
setFocused(false);
} else {
setError(false);
setFocused(false);
}
}
onBlur && onBlur();
};
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const inputValue = e.target.value;
if (required && !inputValue) {
setError(true);
setHasContent(false);
} else {
setError(false);
setHasContent(Boolean(inputValue));
}
if (value === undefined) {
setInternalValue(inputValue);
}
onChange && onChange(inputValue);
handleAutoResize(e.target);
};
const handleAutoResize = (textarea: HTMLTextAreaElement) => {
textarea.style.height = "auto"; // Reset height to calculate new height
textarea.style.height = `${textarea.scrollHeight}px`; // Set new height based on content
};
useEffect(() => {
if (internalInputRef.current) {
handleAutoResize(internalInputRef.current);
}
}, [internalValue]);
const inputValue = value ?? internalValue;
return (
<div
ref={ref}
className={clsx("input", className, {
focused: focused,
error: error,
disabled: disabled,
"no-label": !label,
})}
onFocus={handleComponentFocus}
onBlur={handleComponentBlur}
tabIndex={-1}
>
{decoration?.startDecoration && <>{decoration.startDecoration}</>}
<div className="input-inner">
{label && (
<label className={clsx("input-inner-label")} htmlFor={label}>
{label}
</label>
)}
<textarea
className={clsx("input-inner-input", {
"offset-left": decoration?.startDecoration,
})}
ref={handleSetInputRef}
id={label}
value={inputValue}
onChange={handleInputChange}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={onKeyDown}
placeholder={placeholder}
maxLength={maxLength}
autoFocus={autoFocus}
disabled={disabled}
rows={1}
onInput={(e) => handleAutoResize(e.currentTarget)}
/>
</div>
{decoration?.endDecoration && <>{decoration.endDecoration}</>}
</div>
);
}
);
export { InputMultiLine };
export type { InputDecorationProps, InputMultiLineProps };