feat: auto-growing Textarea

This commit is contained in:
molvqingtai 2023-07-31 00:32:00 +08:00
parent dc06eba105
commit 98268ce09f
8 changed files with 919 additions and 123 deletions

View file

@ -91,14 +91,18 @@
"@radix-ui/react-avatar": "^1.0.3", "@radix-ui/react-avatar": "^1.0.3",
"@radix-ui/react-hover-card": "^1.0.6", "@radix-ui/react-hover-card": "^1.0.6",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-scroll-area": "^1.0.4",
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "^1.0.2",
"@tailwindcss/typography": "^0.5.9",
"class-variance-authority": "^0.6.1", "class-variance-authority": "^0.6.1",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
"lucide-react": "^0.263.0", "lucide-react": "^0.263.0",
"peerjs": "^1.4.7", "peerjs": "^1.4.7",
"react-markdown": "^8.0.7",
"react-nice-avatar": "^1.4.1", "react-nice-avatar": "^1.4.1",
"react-use": "^17.4.0", "react-use": "^17.4.0",
"remark-gfm": "^3.0.1",
"tailwind-merge": "^1.13.2", "tailwind-merge": "^1.13.2",
"type-fest": "^3.13.0" "type-fest": "^3.13.0"
}, },

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,63 @@
import { type FC, type ChangeEvent } from 'react'
import { Textarea } from '@/components/ui/Textarea'
import { Markdown } from '@/components/ui/Markdown'
import { cn } from '@/utils'
export interface MessageInputProps {
value?: string
focus?: boolean
preview?: boolean
className?: string
maxLength?: number
onInput?: (message: string) => void
onChange?: (message: string) => void
onFocus?: () => void
onBlur?: () => void
}
const MessageInput: FC<MessageInputProps> = ({
value,
focus = true,
className,
preview,
maxLength = 500,
onInput,
onChange,
onFocus,
onBlur
}) => {
return (
<div className={cn('relative', className)}>
{preview ? (
<Markdown className="max-h-32 rounded-lg border border-input bg-gray-50 2xl:max-h-40">{value}</Markdown>
) : (
// Hack: Auto-Growing Textarea
<div
data-value={value}
className="grid after:pointer-events-none after:invisible after:col-start-1 after:col-end-2 after:row-start-1 after:row-end-2 after:box-border after:max-h-28 after:w-full after:overflow-x-hidden after:whitespace-pre-wrap after:break-words after:rounded-lg after:border after:px-3 after:py-2 after:pb-5 after:text-sm after:content-[attr(data-value)] after:2xl:max-h-40"
>
<Textarea
autoFocus={focus}
maxLength={maxLength}
className="col-start-1 col-end-2 row-start-1 row-end-2 box-border max-h-28 resize-none overflow-x-hidden break-words rounded-lg bg-gray-50 pb-5 text-sm 2xl:max-h-40"
rows={2}
value={value}
placeholder="Type your message here."
onInput={(e: ChangeEvent<HTMLTextAreaElement>) => onInput?.(e.target.value)}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => onChange?.(e.target.value)}
onFocus={onFocus}
onBlur={onBlur}
/>
</div>
)}
<div className="absolute bottom-1 right-3 rounded-lg text-xs text-slate-400">
{value?.length ?? 0}/{maxLength}
</div>
</div>
)
}
MessageInput.displayName = 'MessageInput'
export default MessageInput

View file

@ -1,36 +1,28 @@
import { useState, type FC, type ChangeEvent } from 'react' import { useState, type FC } from 'react'
import { Textarea } from '@/components/ui/Textarea'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { SmileIcon, CommandIcon, CornerDownLeftIcon } from 'lucide-react' import { SmileIcon, CornerDownLeftIcon, ImageIcon } from 'lucide-react'
import { useBreakpoint } from '@/hooks/useBreakpoint' import MessageInput from './MessageInput'
const Footer: FC = () => { const Footer: FC = () => {
const { is2XL } = useBreakpoint()
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const handleInput = (e: ChangeEvent<HTMLTextAreaElement>) => { const handleInput = (value: string) => {
setMessage(e.target.value) setMessage(value)
} }
return ( return (
<div className="grid gap-y-2 p-4"> <div className="grid gap-y-2 p-4">
<Textarea <MessageInput value={message} onInput={handleInput}></MessageInput>
className="rounded-lg bg-gray-50"
rows={is2XL ? 3 : 2}
value={message}
placeholder="Type your message here."
onInput={handleInput}
/>
<div className="grid grid-cols-[auto_auto_1fr] items-center justify-items-end"> <div className="grid grid-cols-[auto_auto_1fr] items-center justify-items-end">
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
<SmileIcon size={20} /> <SmileIcon size={20} />
</Button> </Button>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
<SmileIcon size={20} /> <ImageIcon size={20} />
</Button> </Button>
<Button size="sm"> <Button size="sm">
<span className="mr-2">Send</span> <span className="mr-2">Send</span>
<CommandIcon className="text-slate-400" size={12}></CommandIcon>
<CornerDownLeftIcon className="text-slate-400" size={12}></CornerDownLeftIcon> <CornerDownLeftIcon className="text-slate-400" size={12}></CornerDownLeftIcon>
</Button> </Button>
</div> </div>

View file

@ -38,7 +38,7 @@ const Main: FC = () => {
} }
] ]
return ( return (
<div className="grid content-start p-4"> <div className="grid content-start overflow-y-auto p-4">
{messages.map((message) => ( {messages.map((message) => (
<Message key={message.id} data={message} /> <Message key={message.id} data={message} />
))} ))}

View file

@ -0,0 +1,70 @@
import { cn } from '@/utils'
import { type FC } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
export interface MarkdownProps {
children?: string
className?: string
}
const Markdown: FC<MarkdownProps> = ({ children = '', className }) => {
return (
<ReactMarkdown
components={{
h1: ({ className, ...props }) => <h1 className={cn('mb-2 mt-0 text-2xl', className)} {...props} />,
h2: ({ className, ...props }) => <h2 className={cn('mb-2 mt-0', className)} {...props} />,
img: ({ className, alt, ...props }) => (
<img className={cn('my-2 max-w-[50%] rounded-md border', className)} alt={alt} {...props} />
),
ul: ({ className, ...props }) => {
Reflect.deleteProperty(props, 'ordered')
return <ul className={cn('text-sm [&:not([depth="0"])]:my-0 ', className)} {...props} />
},
input: ({ className, ...props }) => <input className={cn('my-0', className)} {...props} />,
table: ({ className, ...props }) => (
<div className="my-4 w-full overflow-y-auto">
<table className={cn('my-0 w-full rounded-md', className)} {...props} />
</div>
),
tr: ({ className, ...props }) => {
// fix: spell it as lowercase `isheader` warning
Reflect.deleteProperty(props, 'isHeader')
return <tr className={cn('m-0 border-t p-0 even:bg-muted', className)} {...props} />
},
th: ({ className, ...props }) => {
// fix: spell it as lowercase `isheader` warning
Reflect.deleteProperty(props, 'isHeader')
return (
<th
className={cn(
'border px-3 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right',
className
)}
{...props}
/>
)
},
td: ({ className, ...props }) => {
return (
<td
className={cn(
'border px-3 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right',
className
)}
{...props}
/>
)
}
}}
remarkPlugins={[remarkGfm]}
className={cn(className, 'prose prose-sm prose-slate')}
>
{children}
</ReactMarkdown>
)
}
Markdown.displayName = 'Markdown'
export { Markdown }

View file

@ -0,0 +1,38 @@
import * as React from 'react'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import { cn } from '@/utils/index'
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn('relative overflow-hidden', className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' && 'h-2.5 border-t border-t-transparent p-[1px]',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View file

@ -1,4 +1,5 @@
import { type Config } from 'tailwindcss' import { type Config } from 'tailwindcss'
import typography from '@tailwindcss/typography'
export default { export default {
darkMode: ['class'], darkMode: ['class'],
@ -67,5 +68,6 @@ export default {
'accordion-up': 'accordion-up 0.2s ease-out' 'accordion-up': 'accordion-up 0.2s ease-out'
} }
} }
} },
plugins: [typography()]
} satisfies Config } satisfies Config