feat: auto-growing Textarea
This commit is contained in:
parent
dc06eba105
commit
98268ce09f
8 changed files with 919 additions and 123 deletions
|
@ -91,14 +91,18 @@
|
|||
"@radix-ui/react-avatar": "^1.0.3",
|
||||
"@radix-ui/react-hover-card": "^1.0.6",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-scroll-area": "^1.0.4",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"class-variance-authority": "^0.6.1",
|
||||
"clsx": "^1.2.1",
|
||||
"date-fns": "^2.30.0",
|
||||
"lucide-react": "^0.263.0",
|
||||
"peerjs": "^1.4.7",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-nice-avatar": "^1.4.1",
|
||||
"react-use": "^17.4.0",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"tailwind-merge": "^1.13.2",
|
||||
"type-fest": "^3.13.0"
|
||||
},
|
||||
|
|
837
pnpm-lock.yaml
837
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
63
src/components/Footer/MessageInput.tsx
Normal file
63
src/components/Footer/MessageInput.tsx
Normal 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
|
|
@ -1,36 +1,28 @@
|
|||
import { useState, type FC, type ChangeEvent } from 'react'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
import { useState, type FC } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { SmileIcon, CommandIcon, CornerDownLeftIcon } from 'lucide-react'
|
||||
import { useBreakpoint } from '@/hooks/useBreakpoint'
|
||||
import { SmileIcon, CornerDownLeftIcon, ImageIcon } from 'lucide-react'
|
||||
import MessageInput from './MessageInput'
|
||||
|
||||
const Footer: FC = () => {
|
||||
const { is2XL } = useBreakpoint()
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
const handleInput = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setMessage(e.target.value)
|
||||
const handleInput = (value: string) => {
|
||||
setMessage(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-y-2 p-4">
|
||||
<Textarea
|
||||
className="rounded-lg bg-gray-50"
|
||||
rows={is2XL ? 3 : 2}
|
||||
value={message}
|
||||
placeholder="Type your message here."
|
||||
onInput={handleInput}
|
||||
/>
|
||||
<MessageInput value={message} onInput={handleInput}></MessageInput>
|
||||
<div className="grid grid-cols-[auto_auto_1fr] items-center justify-items-end">
|
||||
<Button variant="ghost" size="icon">
|
||||
<SmileIcon size={20} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon">
|
||||
<SmileIcon size={20} />
|
||||
<ImageIcon size={20} />
|
||||
</Button>
|
||||
<Button size="sm">
|
||||
<span className="mr-2">Send</span>
|
||||
<CommandIcon className="text-slate-400" size={12}></CommandIcon>
|
||||
<CornerDownLeftIcon className="text-slate-400" size={12}></CornerDownLeftIcon>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
@ -38,7 +38,7 @@ const Main: FC = () => {
|
|||
}
|
||||
]
|
||||
return (
|
||||
<div className="grid content-start p-4">
|
||||
<div className="grid content-start overflow-y-auto p-4">
|
||||
{messages.map((message) => (
|
||||
<Message key={message.id} data={message} />
|
||||
))}
|
||||
|
|
70
src/components/ui/Markdown.tsx
Normal file
70
src/components/ui/Markdown.tsx
Normal 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 }
|
38
src/components/ui/ScrollArea.tsx
Normal file
38
src/components/ui/ScrollArea.tsx
Normal 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 }
|
|
@ -1,4 +1,5 @@
|
|||
import { type Config } from 'tailwindcss'
|
||||
import typography from '@tailwindcss/typography'
|
||||
|
||||
export default {
|
||||
darkMode: ['class'],
|
||||
|
@ -67,5 +68,6 @@ export default {
|
|||
'accordion-up': 'accordion-up 0.2s ease-out'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [typography()]
|
||||
} satisfies Config
|
||||
|
|
Loading…
Reference in a new issue