mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-04 18:59:08 +01:00
ChatInput component
This commit is contained in:
parent
738502b95c
commit
601533566e
66
frontend/app/element/chatinput.less
Normal file
66
frontend/app/element/chatinput.less
Normal 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 */
|
||||||
|
}
|
||||||
|
}
|
104
frontend/app/element/chatinput.stories.tsx
Normal file
104
frontend/app/element/chatinput.stories.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
128
frontend/app/element/chatinput.tsx
Normal file
128
frontend/app/element/chatinput.tsx
Normal 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 };
|
@ -11,9 +11,6 @@ const meta: Meta<typeof EmojiPalette> = {
|
|||||||
className: "custom-emoji-palette-class",
|
className: "custom-emoji-palette-class",
|
||||||
},
|
},
|
||||||
argTypes: {
|
argTypes: {
|
||||||
scopeRef: {
|
|
||||||
description: "Reference to the outer container element for positioning",
|
|
||||||
},
|
|
||||||
className: {
|
className: {
|
||||||
description: "Custom class for emoji palette styling",
|
description: "Custom class for emoji palette styling",
|
||||||
},
|
},
|
||||||
|
@ -1,19 +1,15 @@
|
|||||||
// Copyright 2024, Command Line Inc.
|
// Copyright 2024, Command Line Inc.
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
import { type Placement } from "@floating-ui/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import React, { memo, useState } from "react";
|
import { memo, useState } from "react";
|
||||||
import { Button } from "./button";
|
import { Button } from "./button";
|
||||||
import { Input } from "./input";
|
import { Input, InputGroup, InputLeftElement } from "./input";
|
||||||
import { Palette, PaletteButton, PaletteContent } from "./palette";
|
import { Palette, PaletteButton, PaletteContent } from "./palette";
|
||||||
|
|
||||||
import "./emojiPalette.less";
|
import "./emojiPalette.less";
|
||||||
|
|
||||||
interface EmojiPaletteProps {
|
|
||||||
scopeRef: React.RefObject<HTMLElement>;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emojiList = [
|
const emojiList = [
|
||||||
// Smileys & Emotion
|
// Smileys & Emotion
|
||||||
{ emoji: "😀", name: "grinning face" },
|
{ emoji: "😀", name: "grinning face" },
|
||||||
@ -217,9 +213,10 @@ const emojiList = [
|
|||||||
|
|
||||||
interface EmojiPaletteProps {
|
interface EmojiPaletteProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
placement?: Placement;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EmojiPalette = memo(({ className }: EmojiPaletteProps) => {
|
const EmojiPalette = memo(({ className, placement }: EmojiPaletteProps) => {
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
const handleSearchChange = (val: string) => {
|
const handleSearchChange = (val: string) => {
|
||||||
@ -230,12 +227,17 @@ const EmojiPalette = memo(({ className }: EmojiPaletteProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx("emoji-palette", className)}>
|
<div className={clsx("emoji-palette", className)}>
|
||||||
<Palette>
|
<Palette placement={placement}>
|
||||||
<PaletteButton className="ghost grey">
|
<PaletteButton className="ghost grey">
|
||||||
<i className="fa-sharp fa-solid fa-face-smile"></i>
|
<i className="fa-sharp fa-solid fa-face-smile"></i>
|
||||||
</PaletteButton>
|
</PaletteButton>
|
||||||
<PaletteContent className="emoji-palette-content">
|
<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">
|
<div className="emoji-grid">
|
||||||
{filteredEmojis.length > 0 ? (
|
{filteredEmojis.length > 0 ? (
|
||||||
filteredEmojis.map((item, index) => (
|
filteredEmojis.map((item, index) => (
|
||||||
|
@ -70,18 +70,3 @@ export const InputWithLeftAndRightElement: Story = {
|
|||||||
className: "custom-input-group-class",
|
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",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
@ -69,8 +69,6 @@ interface InputProps {
|
|||||||
isNumber?: boolean;
|
isNumber?: boolean;
|
||||||
inputRef?: React.MutableRefObject<any>;
|
inputRef?: React.MutableRefObject<any>;
|
||||||
manageFocus?: (isFocused: boolean) => void;
|
manageFocus?: (isFocused: boolean) => void;
|
||||||
multiLine?: boolean;
|
|
||||||
rows?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Input = forwardRef<HTMLDivElement, InputProps>(
|
const Input = forwardRef<HTMLDivElement, InputProps>(
|
||||||
@ -92,8 +90,6 @@ const Input = forwardRef<HTMLDivElement, InputProps>(
|
|||||||
isNumber,
|
isNumber,
|
||||||
inputRef,
|
inputRef,
|
||||||
manageFocus,
|
manageFocus,
|
||||||
multiLine = false,
|
|
||||||
rows = 1,
|
|
||||||
}: InputProps,
|
}: InputProps,
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
@ -138,36 +134,20 @@ const Input = forwardRef<HTMLDivElement, InputProps>(
|
|||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
{multiLine ? (
|
|
||||||
<textarea
|
<input
|
||||||
className={clsx("input-inner-textarea")}
|
className={clsx("input-inner-input")}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
maxLength={maxLength}
|
maxLength={maxLength}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
disabled={disabled}
|
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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -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
|
|
||||||
}
|
|
@ -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...",
|
|
||||||
},
|
|
||||||
};
|
|
@ -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 };
|
|
Loading…
Reference in New Issue
Block a user