From 12cd9c85d03203d7b9fc30b07c0ae4c9def4a186 Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Mon, 24 Feb 2025 18:28:52 -0700 Subject: [PATCH] working text area --- web/src/components/ui/inputs/Input.tsx | 4 +- .../ui/inputs/InputTextArea.stories.tsx | 63 ++++++++++ .../components/ui/inputs/InputTextArea.tsx | 116 ++++++++++++++++++ 3 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 web/src/components/ui/inputs/InputTextArea.stories.tsx create mode 100644 web/src/components/ui/inputs/InputTextArea.tsx diff --git a/web/src/components/ui/inputs/Input.tsx b/web/src/components/ui/inputs/Input.tsx index 011923993..76b48db6f 100644 --- a/web/src/components/ui/inputs/Input.tsx +++ b/web/src/components/ui/inputs/Input.tsx @@ -3,7 +3,7 @@ import { cn } from '@/lib/classMerge'; import { cva, type VariantProps } from 'class-variance-authority'; import { useMemoizedFn } from 'ahooks'; -const inputVariants = cva( +export const inputVariants = cva( 'flex w-full rounded border px-2.5 text-base transition-all duration-200 disabled:cursor-not-allowed disabled:bg-item-select disabled:text-gray-light ', { variants: { @@ -49,7 +49,7 @@ export const Input = React.forwardRef( return ( = { + title: 'Base/InputTextArea', + component: InputTextArea, + tags: ['autodocs'], + args: { + autoResize: { + minRows: 1, + maxRows: 4 + } + }, + argTypes: { + variant: { + control: 'select', + options: ['default', 'ghost'] + }, + disabled: { + control: 'boolean' + }, + placeholder: { + control: 'text' + }, + rows: { + control: 'number' + } + } +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + placeholder: 'Enter text here...', + rows: 4 + } +}; + +export const Ghost: Story = { + args: { + variant: 'ghost', + placeholder: 'Ghost textarea...', + rows: 4 + } +}; + +export const Disabled: Story = { + args: { + disabled: true, + placeholder: 'Disabled textarea', + value: 'Cannot edit this text', + rows: 4 + } +}; + +export const LargeRows: Story = { + args: { + placeholder: 'Large textarea...', + rows: 8 + } +}; diff --git a/web/src/components/ui/inputs/InputTextArea.tsx b/web/src/components/ui/inputs/InputTextArea.tsx new file mode 100644 index 000000000..5af70d03a --- /dev/null +++ b/web/src/components/ui/inputs/InputTextArea.tsx @@ -0,0 +1,116 @@ +'use client'; + +import React, { useEffect, useRef } from 'react'; +import { cn } from '@/lib/classMerge'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { inputVariants } from './Input'; + +const inputTextAreaVariants = inputVariants; + +interface AutoResizeOptions { + minRows?: number; + maxRows?: number; +} + +export interface InputTextAreaProps + extends React.TextareaHTMLAttributes, + VariantProps { + autoResize?: AutoResizeOptions; +} + +export const InputTextArea = React.forwardRef( + ({ className, variant = 'default', autoResize, style, rows = 1, ...props }, ref) => { + const textareaRef = useRef(null); + + const combinedRef = (node: HTMLTextAreaElement) => { + textareaRef.current = node; + if (typeof ref === 'function') { + ref(node); + } else if (ref) { + ref.current = node; + } + }; + + const calculateMinHeight = () => { + const textarea = textareaRef.current; + if (!textarea || !autoResize) return null; + + const computedStyle = window.getComputedStyle(textarea); + const lineHeight = + parseFloat(computedStyle.lineHeight) || parseFloat(computedStyle.fontSize) * 1.2; + const paddingTop = parseFloat(computedStyle.paddingTop); + const paddingBottom = parseFloat(computedStyle.paddingBottom); + + return (autoResize.minRows || rows) * lineHeight + paddingTop + paddingBottom; + }; + + const adjustHeight = () => { + const textarea = textareaRef.current; + if (!textarea || !autoResize) return; + + const minHeight = calculateMinHeight(); + if (!minHeight) return; + + // Reset the height to auto first to shrink properly + textarea.style.height = 'auto'; + + const computedStyle = window.getComputedStyle(textarea); + const lineHeight = + parseFloat(computedStyle.lineHeight) || parseFloat(computedStyle.fontSize) * 1.2; + const maxHeight = autoResize.maxRows + ? autoResize.maxRows * lineHeight + + parseFloat(computedStyle.paddingTop) + + parseFloat(computedStyle.paddingBottom) + : Infinity; + + // Get the scroll height after resetting to auto + const scrollHeight = Math.max(textarea.scrollHeight, minHeight); + const newHeight = Math.min(scrollHeight, maxHeight); + + // Apply the new height + textarea.style.height = `${newHeight}px`; + textarea.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden'; + }; + + useEffect(() => { + const textarea = textareaRef.current; + if (!textarea || !autoResize) return; + + const minHeight = calculateMinHeight(); + if (minHeight) { + textarea.style.minHeight = `${minHeight}px`; + } + + // Set initial height + adjustHeight(); + + // Add event listeners + const handleInput = () => { + requestAnimationFrame(adjustHeight); + }; + + textarea.addEventListener('input', handleInput); + window.addEventListener('resize', adjustHeight); + + return () => { + textarea.removeEventListener('input', handleInput); + window.removeEventListener('resize', adjustHeight); + }; + }, [autoResize]); + + return ( +