From 4dd4acfa8c5f7c795188bcbf7d75e29193f96096 Mon Sep 17 00:00:00 2001 From: Red Adaya Date: Thu, 10 Oct 2024 19:52:06 +0800 Subject: [PATCH] save work --- frontend/app/element/emojipalette.tsx | 1 - frontend/app/element/inputmultiline.less | 96 +++++++++ .../app/element/inputmultiline.stories.tsx | 52 +++++ frontend/app/element/intputmultiline.tsx | 184 ++++++++++++++++++ 4 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 frontend/app/element/inputmultiline.less create mode 100644 frontend/app/element/inputmultiline.stories.tsx create mode 100644 frontend/app/element/intputmultiline.tsx diff --git a/frontend/app/element/emojipalette.tsx b/frontend/app/element/emojipalette.tsx index 2873d6ee9..77ba85e51 100644 --- a/frontend/app/element/emojipalette.tsx +++ b/frontend/app/element/emojipalette.tsx @@ -251,7 +251,6 @@ const EmojiPalette = memo(({ scopeRef, className }: EmojiPaletteProps) => { {isPaletteVisible && ( -
{filteredEmojis.length > 0 ? ( filteredEmojis.map((item, index) => ( diff --git a/frontend/app/element/inputmultiline.less b/frontend/app/element/inputmultiline.less new file mode 100644 index 000000000..66aff911a --- /dev/null +++ b/frontend/app/element/inputmultiline.less @@ -0,0 +1,96 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.input { + display: flex; + align-items: flex-start; + border-radius: 6px; + position: relative; + min-height: 24px; + min-width: 50px; + width: 100%; + gap: 6px; + border: 2px solid var(--form-element-border-color); + background: var(--form-element-bg-color); + padding: 8px; + + &:hover { + cursor: text; + } + + &.focused { + border-color: var(--form-element-primary-color); + } + + &.disabled { + opacity: 0.75; + } + + &.error { + border-color: var(--form-element-error-color); + } + + &-inner { + display: flex; + flex-direction: column; + position: relative; + flex-grow: 1; + --inner-padding: 5px 0 5px 16px; + + &-label { + padding: var(--inner-padding); + margin-bottom: -10px; + font-size: 12.5px; + transition: all 0.3s; + color: var(--form-element-label-color); + line-height: 10px; + user-select: none; + + &.float { + font-size: 10px; + top: 5px; + } + + &.offset-left { + left: 0; + } + } + + &-input { + width: 100%; + height: 100%; + border: none; + padding: var(--inner-padding); + font-size: 12px; + outline: none; + background-color: transparent; + color: var(--form-element-text-color); + line-height: 20px; + resize: none; // Disable manual resizing by the user + + &.offset-left { + padding: 5px 16px 5px 0; + } + + &:placeholder-shown { + user-select: none; + } + } + } + + &.no-label { + height: auto; + + textarea { + height: auto; + } + } +} + +// 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 +} diff --git a/frontend/app/element/inputmultiline.stories.tsx b/frontend/app/element/inputmultiline.stories.tsx new file mode 100644 index 000000000..57aea021e --- /dev/null +++ b/frontend/app/element/inputmultiline.stories.tsx @@ -0,0 +1,52 @@ +// 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 = { + 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; + +export const DefaultInput: Story = { + render: (args) => { + return ; + }, + args: { + label: "Message", + placeholder: "Type your message...", + }, +}; + +export const InputWithErrorState: Story = { + render: (args) => { + return ; + }, + args: { + label: "Required Message", + placeholder: "This field is required...", + }, +}; diff --git a/frontend/app/element/intputmultiline.tsx b/frontend/app/element/intputmultiline.tsx new file mode 100644 index 000000000..e49bfd449 --- /dev/null +++ b/frontend/app/element/intputmultiline.tsx @@ -0,0 +1,184 @@ +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) => void; + onFocus?: () => void; + onBlur?: () => void; + placeholder?: string; + defaultValue?: string; + decoration?: InputDecorationProps; + required?: boolean; + maxLength?: number; + autoFocus?: boolean; + disabled?: boolean; + inputRef?: React.MutableRefObject; +} + +const InputMultiLine = forwardRef( + ( + { + 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(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) => { + 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 ( +
+ {decoration?.startDecoration && <>{decoration.startDecoration}} +
+ {label && ( + + )} +