multiline input

This commit is contained in:
Red Adaya 2024-10-21 08:33:57 +08:00
parent 26a2ce250f
commit 719831d254
8 changed files with 263 additions and 291 deletions

View File

@ -1,64 +0,0 @@
.chat-group {
display: flex;
align-items: stretch;
border-radius: 6px;
position: relative;
width: 100%;
border: 2px solid var(--form-element-border-color);
background: var(--form-element-bg-color);
padding: 5px;
gap: 10px;
&.focused {
border-color: var(--form-element-primary-color);
}
&.error {
border-color: var(--form-element-error-color);
}
&.disabled {
opacity: 0.75;
}
&:hover {
cursor: text;
}
.input-left-element,
.input-right-element {
padding: 0 5px;
display: flex;
align-items: center;
justify-content: center;
}
.chat-textarea {
flex-grow: 1;
margin: 0;
border: none;
box-shadow: none;
box-sizing: border-box;
background-color: transparent;
resize: none;
overflow-y: auto;
line-height: 1.4;
color: var(--form-element-text-color);
vertical-align: top;
height: auto;
padding: 0;
&:focus-visible {
outline: none;
}
}
.emoji-palette-wrapper {
display: flex;
align-items: flex-end;
button {
padding: 3px 4px;
}
}
}

View File

@ -1,132 +0,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?: (e: React.ChangeEvent<HTMLTextAreaElement>) => 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
// Function to count the number of lines in the textarea value
const countLines = (text: string) => {
return text.split("\n").length;
};
const adjustTextareaHeight = () => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto"; // Reset height to auto first
const maxHeight = maxRows * lineHeight; // Max height based on maxRows
const currentLines = countLines(textareaRef.current.value); // Count the number of lines
const newHeight = Math.min(textareaRef.current.scrollHeight, maxHeight); // Calculate new height
// If the number of lines is less than or equal to maxRows, set height accordingly
const calculatedHeight = currentLines <= maxRows ? `${lineHeight * currentLines}px` : `${newHeight}px`;
textareaRef.current.style.height = calculatedHeight; // Set new height based on lines or scrollHeight
if (actionWrapperRef.current) {
actionWrapperRef.current.style.height = calculatedHeight; // Adjust emoji palette wrapper height
}
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInternalValue(e.target.value);
onChange?.(e);
// Adjust the height of the textarea after text change
adjustTextareaHeight();
};
const handleFocus = () => {
manageFocus?.(true);
onFocus?.();
};
const handleBlur = () => {
manageFocus?.(false);
onBlur?.();
};
useEffect(() => {
if (textareaRef.current) {
const computedStyle = window.getComputedStyle(textareaRef.current);
let lineHeightValue = computedStyle.lineHeight;
const detectedLineHeight = parseFloat(lineHeightValue);
setLineHeight(detectedLineHeight);
}
}, [textareaRef]);
useEffect(() => {
adjustTextareaHeight(); // Adjust the height when the component mounts or value changes
}, [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} className="emoji-palette-wrapper">
<EmojiPalette placement="top-end" />
</div>
</div>
);
};
export { ChatInput };

View File

@ -18,10 +18,6 @@
cursor: text;
}
&.focused {
border-color: var(--form-element-primary-color);
}
&.disabled {
opacity: 0.75;
}
@ -58,8 +54,8 @@
line-height: 24;
padding: 5px 7px;
&:placeholder-shown {
user-select: none;
&:focus {
border-color: var(--form-element-primary-color);
}
}
}
@ -104,17 +100,17 @@
/* Ensure the input elements inside the group behave correctly */
.input-wrapper {
border: none; /* Remove the individual input border */
border: none;
flex-grow: 1;
margin: 0;
box-shadow: none;
&.focused {
border-color: transparent; /* Make sure individual input focus doesn't override group focus */
border-color: transparent;
}
&.error {
border-color: transparent; /* Make sure individual input error doesn't override group error */
border-color: transparent;
}
.input-wrapper-inner {

View File

@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
import clsx from "clsx";
import React, { forwardRef, useState } from "react";
import React, { forwardRef, memo, useImperativeHandle, useRef, useState } from "react";
import "./input.less";
@ -71,87 +71,91 @@ interface InputProps {
manageFocus?: (isFocused: boolean) => void;
}
const Input = forwardRef<HTMLDivElement, InputProps>(
(
{
label,
value,
className,
onChange,
onKeyDown,
onFocus,
onBlur,
placeholder,
defaultValue = "",
required,
maxLength,
autoFocus,
disabled,
isNumber,
inputRef,
manageFocus,
}: InputProps,
ref
) => {
const [internalValue, setInternalValue] = useState(defaultValue);
const Input = memo(
forwardRef<HTMLInputElement, InputProps>(
(
{
label,
value,
className,
onChange,
onKeyDown,
onFocus,
onBlur,
placeholder,
defaultValue = "",
required,
maxLength,
autoFocus,
disabled,
isNumber,
manageFocus,
}: InputProps,
ref
) => {
const [internalValue, setInternalValue] = useState(defaultValue);
const inputRef = useRef<HTMLInputElement>(null);
const handleInputChange = (e: React.ChangeEvent<any>) => {
const inputValue = e.target.value;
useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);
if (isNumber && inputValue !== "" && !/^\d*$/.test(inputValue)) {
return;
}
const handleInputChange = (e: React.ChangeEvent<any>) => {
const inputValue = e.target.value;
if (value === undefined) {
setInternalValue(inputValue);
}
if (isNumber && inputValue !== "" && !/^\d*$/.test(inputValue)) {
return;
}
onChange && onChange(inputValue);
};
if (value === undefined) {
setInternalValue(inputValue);
}
const handleFocus = () => {
manageFocus?.(true);
onFocus?.();
};
onChange && onChange(inputValue);
};
const handleBlur = () => {
manageFocus?.(false);
onBlur?.();
};
const handleFocus = () => {
manageFocus?.(true);
onFocus?.();
};
const inputValue = value ?? internalValue;
const handleBlur = () => {
manageFocus?.(false);
onBlur?.();
};
return (
<div
ref={ref}
className={clsx("input-wrapper", className, {
disabled: disabled,
})}
>
<div className="input-wrapper-inner">
{label && (
<label className={clsx("label")} htmlFor={label}>
{label}
</label>
)}
const inputValue = value ?? internalValue;
<input
className={clsx("input")}
ref={inputRef}
value={inputValue}
onChange={handleInputChange}
onKeyDown={onKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={placeholder}
maxLength={maxLength}
autoFocus={autoFocus}
disabled={disabled}
/>
return (
<div
ref={ref}
className={clsx("input-wrapper", className, {
disabled: disabled,
})}
>
<div className="input-wrapper-inner">
{label && (
<label className={clsx("label")} htmlFor={label}>
{label}
</label>
)}
<input
className={clsx("input")}
ref={inputRef}
value={inputValue}
onChange={handleInputChange}
onKeyDown={onKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={placeholder}
maxLength={maxLength}
autoFocus={autoFocus}
disabled={disabled}
/>
</div>
</div>
</div>
);
}
);
}
)
);
export { Input, InputGroup, InputLeftElement, InputRightElement };

View File

@ -0,0 +1,24 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
.multiline-input {
flex-grow: 1;
box-shadow: none;
box-sizing: border-box;
background-color: transparent;
resize: none;
overflow-y: auto;
line-height: 1.5;
color: var(--form-element-text-color);
vertical-align: top;
height: auto;
padding: 0;
border: 1px solid var(--form-element-border-color);
padding: 5px;
border-radius: 4px;
min-height: 26px;
&:focus-visible {
outline: none;
}
}

View File

@ -3,11 +3,11 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { ChatInput } from "./chatinput";
import { MultiLineInput } from "./multilineinput";
const meta: Meta<typeof ChatInput> = {
title: "Elements/ChatInput",
component: ChatInput,
const meta: Meta<typeof MultiLineInput> = {
title: "Elements/MultiLineInput",
component: MultiLineInput,
argTypes: {
value: {
description: "The value of the textarea.",
@ -47,10 +47,10 @@ const meta: Meta<typeof ChatInput> = {
};
export default meta;
type Story = StoryObj<typeof ChatInput>;
type Story = StoryObj<typeof MultiLineInput>;
// Default ChatInput Story
export const DefaultChatInput: Story = {
// Default MultiLineInput Story
export const DefaultMultiLineInput: Story = {
render: (args) => {
const [message, setMessage] = useState("");
@ -65,11 +65,11 @@ export const DefaultChatInput: Story = {
height: "600px",
padding: "20px",
display: "flex",
alignItems: "flex-end",
alignItems: "flex-start",
justifyContent: "center",
}}
>
<ChatInput {...args} value={message} onChange={handleChange} />
<MultiLineInput {...args} value={message} onChange={handleChange} />
</div>
);
},
@ -80,8 +80,8 @@ export const DefaultChatInput: Story = {
},
};
// ChatInput with long text
export const ChatInputWithLongText: Story = {
// MultiLineInput with long text
export const MultiLineInputWithLongText: Story = {
render: (args) => {
const [message, setMessage] = useState("This is a long message that will expand the textarea.");
@ -96,11 +96,11 @@ export const ChatInputWithLongText: Story = {
height: "600px",
padding: "20px",
display: "flex",
alignItems: "flex-end",
alignItems: "flex-start",
justifyContent: "center",
}}
>
<ChatInput {...args} value={message} onChange={handleChange} />
<MultiLineInput {...args} value={message} onChange={handleChange} />
</div>
);
},

View File

@ -0,0 +1,144 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import clsx from "clsx";
import React, { forwardRef, memo, useEffect, useImperativeHandle, useRef, useState } from "react";
import "./multilineinput.less";
interface MultiLineInputProps {
value?: string;
className?: string;
onChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => 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 MultiLineInput = memo(
forwardRef<HTMLTextAreaElement, MultiLineInputProps>(
(
{
value,
className,
onChange,
onKeyDown,
onFocus,
onBlur,
placeholder,
defaultValue = "",
maxLength,
autoFocus,
disabled,
rows = 1,
maxRows = 5,
manageFocus,
}: MultiLineInputProps,
ref
) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [internalValue, setInternalValue] = useState(defaultValue);
const [lineHeight, setLineHeight] = useState(24); // Default line height fallback of 24px
const [paddingTop, setPaddingTop] = useState(0);
const [paddingBottom, setPaddingBottom] = useState(0);
useImperativeHandle(ref, () => textareaRef.current as HTMLTextAreaElement);
// Function to count the number of lines in the textarea value
const countLines = (text: string) => {
return text.split("\n").length;
};
const adjustTextareaHeight = () => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto"; // Reset height to auto first
const maxHeight = maxRows * lineHeight + paddingTop + paddingBottom; // Max height based on maxRows
const currentLines = countLines(textareaRef.current.value); // Count the number of lines
const newHeight = Math.min(textareaRef.current.scrollHeight, maxHeight); // Calculate new height
// If the number of lines is less than or equal to maxRows, set height accordingly
const calculatedHeight =
currentLines <= maxRows
? `${lineHeight * currentLines + paddingTop + paddingBottom}px`
: `${newHeight}px`;
textareaRef.current.style.height = calculatedHeight;
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInternalValue(e.target.value);
onChange?.(e);
// Adjust the height of the textarea after text change
adjustTextareaHeight();
};
const handleFocus = () => {
manageFocus?.(true);
onFocus?.();
};
const handleBlur = () => {
manageFocus?.(false);
onBlur?.();
};
useEffect(() => {
if (textareaRef.current) {
const computedStyle = window.getComputedStyle(textareaRef.current);
const detectedLineHeight = parseFloat(computedStyle.lineHeight);
const detectedPaddingTop = parseFloat(computedStyle.paddingTop);
const detectedPaddingBottom = parseFloat(computedStyle.paddingBottom);
setLineHeight(detectedLineHeight);
setPaddingTop(detectedPaddingTop);
setPaddingBottom(detectedPaddingBottom);
}
}, [textareaRef]);
useEffect(() => {
adjustTextareaHeight();
}, [value, maxRows, lineHeight, paddingTop, paddingBottom]);
const inputValue = value ?? internalValue;
return (
<textarea
className={clsx("multiline-input", className)}
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 + paddingTop + paddingBottom
? "auto"
: "hidden",
}}
/>
);
}
)
);
export { MultiLineInput };
export type { MultiLineInputProps };

View File

@ -102,7 +102,7 @@ const TypeAheadModal = ({
const width = domRect?.width ?? 0;
const height = domRect?.height ?? 0;
const modalRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const realInputRef = useRef<HTMLInputElement>(null);
const suggestionsWrapperRef = useRef<HTMLDivElement>(null);
const suggestionsRef = useRef<HTMLDivElement>(null);