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-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"
|
||||||
},
|
},
|
||||||
|
|
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 { 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>
|
||||||
|
|
|
@ -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} />
|
||||||
))}
|
))}
|
||||||
|
|
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 { 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
|
||||||
|
|
Loading…
Reference in a new issue