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

View File

@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import clsx from "clsx"; import clsx from "clsx";
import React, { forwardRef, useState } from "react"; import React, { forwardRef, memo, useImperativeHandle, useRef, useState } from "react";
import "./input.less"; import "./input.less";
@ -71,87 +71,91 @@ interface InputProps {
manageFocus?: (isFocused: boolean) => void; manageFocus?: (isFocused: boolean) => void;
} }
const Input = forwardRef<HTMLDivElement, InputProps>( const Input = memo(
( forwardRef<HTMLInputElement, InputProps>(
{ (
label, {
value, label,
className, value,
onChange, className,
onKeyDown, onChange,
onFocus, onKeyDown,
onBlur, onFocus,
placeholder, onBlur,
defaultValue = "", placeholder,
required, defaultValue = "",
maxLength, required,
autoFocus, maxLength,
disabled, autoFocus,
isNumber, disabled,
inputRef, isNumber,
manageFocus, manageFocus,
}: InputProps, }: InputProps,
ref ref
) => { ) => {
const [internalValue, setInternalValue] = useState(defaultValue); const [internalValue, setInternalValue] = useState(defaultValue);
const inputRef = useRef<HTMLInputElement>(null);
const handleInputChange = (e: React.ChangeEvent<any>) => { useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);
const inputValue = e.target.value;
if (isNumber && inputValue !== "" && !/^\d*$/.test(inputValue)) { const handleInputChange = (e: React.ChangeEvent<any>) => {
return; const inputValue = e.target.value;
}
if (value === undefined) { if (isNumber && inputValue !== "" && !/^\d*$/.test(inputValue)) {
setInternalValue(inputValue); return;
} }
onChange && onChange(inputValue); if (value === undefined) {
}; setInternalValue(inputValue);
}
const handleFocus = () => { onChange && onChange(inputValue);
manageFocus?.(true); };
onFocus?.();
};
const handleBlur = () => { const handleFocus = () => {
manageFocus?.(false); manageFocus?.(true);
onBlur?.(); onFocus?.();
}; };
const inputValue = value ?? internalValue; const handleBlur = () => {
manageFocus?.(false);
onBlur?.();
};
return ( const inputValue = value ?? internalValue;
<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 return (
className={clsx("input")} <div
ref={inputRef} ref={ref}
value={inputValue} className={clsx("input-wrapper", className, {
onChange={handleInputChange} disabled: disabled,
onKeyDown={onKeyDown} })}
onFocus={handleFocus} >
onBlur={handleBlur} <div className="input-wrapper-inner">
placeholder={placeholder} {label && (
maxLength={maxLength} <label className={clsx("label")} htmlFor={label}>
autoFocus={autoFocus} {label}
disabled={disabled} </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>
</div> );
); }
} )
); );
export { Input, InputGroup, InputLeftElement, InputRightElement }; 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 type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react"; import { useState } from "react";
import { ChatInput } from "./chatinput"; import { MultiLineInput } from "./multilineinput";
const meta: Meta<typeof ChatInput> = { const meta: Meta<typeof MultiLineInput> = {
title: "Elements/ChatInput", title: "Elements/MultiLineInput",
component: ChatInput, component: MultiLineInput,
argTypes: { argTypes: {
value: { value: {
description: "The value of the textarea.", description: "The value of the textarea.",
@ -47,10 +47,10 @@ const meta: Meta<typeof ChatInput> = {
}; };
export default meta; export default meta;
type Story = StoryObj<typeof ChatInput>; type Story = StoryObj<typeof MultiLineInput>;
// Default ChatInput Story // Default MultiLineInput Story
export const DefaultChatInput: Story = { export const DefaultMultiLineInput: Story = {
render: (args) => { render: (args) => {
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
@ -65,11 +65,11 @@ export const DefaultChatInput: Story = {
height: "600px", height: "600px",
padding: "20px", padding: "20px",
display: "flex", display: "flex",
alignItems: "flex-end", alignItems: "flex-start",
justifyContent: "center", justifyContent: "center",
}} }}
> >
<ChatInput {...args} value={message} onChange={handleChange} /> <MultiLineInput {...args} value={message} onChange={handleChange} />
</div> </div>
); );
}, },
@ -80,8 +80,8 @@ export const DefaultChatInput: Story = {
}, },
}; };
// ChatInput with long text // MultiLineInput with long text
export const ChatInputWithLongText: Story = { export const MultiLineInputWithLongText: Story = {
render: (args) => { render: (args) => {
const [message, setMessage] = useState("This is a long message that will expand the textarea."); const [message, setMessage] = useState("This is a long message that will expand the textarea.");
@ -96,11 +96,11 @@ export const ChatInputWithLongText: Story = {
height: "600px", height: "600px",
padding: "20px", padding: "20px",
display: "flex", display: "flex",
alignItems: "flex-end", alignItems: "flex-start",
justifyContent: "center", justifyContent: "center",
}} }}
> >
<ChatInput {...args} value={message} onChange={handleChange} /> <MultiLineInput {...args} value={message} onChange={handleChange} />
</div> </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 width = domRect?.width ?? 0;
const height = domRect?.height ?? 0; const height = domRect?.height ?? 0;
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const realInputRef = useRef<HTMLInputElement>(null); const realInputRef = useRef<HTMLInputElement>(null);
const suggestionsWrapperRef = useRef<HTMLDivElement>(null); const suggestionsWrapperRef = useRef<HTMLDivElement>(null);
const suggestionsRef = useRef<HTMLDivElement>(null); const suggestionsRef = useRef<HTMLDivElement>(null);