@fr0mpy/component-system 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +283 -0
- package/index.js +12 -0
- package/package.json +45 -0
- package/templates/commands/component-harness.md +116 -0
- package/templates/commands/setup-styling.md +111 -0
- package/templates/component-recipes/accordion.md +153 -0
- package/templates/component-recipes/alert.md +145 -0
- package/templates/component-recipes/avatar.md +165 -0
- package/templates/component-recipes/badge.md +126 -0
- package/templates/component-recipes/breadcrumb.md +220 -0
- package/templates/component-recipes/button.md +90 -0
- package/templates/component-recipes/card.md +130 -0
- package/templates/component-recipes/carousel.md +277 -0
- package/templates/component-recipes/checkbox.md +117 -0
- package/templates/component-recipes/collapsible.md +201 -0
- package/templates/component-recipes/combobox.md +193 -0
- package/templates/component-recipes/context-menu.md +254 -0
- package/templates/component-recipes/dialog.md +193 -0
- package/templates/component-recipes/drawer.md +196 -0
- package/templates/component-recipes/dropdown-menu.md +263 -0
- package/templates/component-recipes/hover-card.md +230 -0
- package/templates/component-recipes/input.md +113 -0
- package/templates/component-recipes/label.md +259 -0
- package/templates/component-recipes/modal.md +155 -0
- package/templates/component-recipes/navigation-menu.md +310 -0
- package/templates/component-recipes/pagination.md +223 -0
- package/templates/component-recipes/popover.md +156 -0
- package/templates/component-recipes/progress.md +185 -0
- package/templates/component-recipes/radio.md +148 -0
- package/templates/component-recipes/select.md +154 -0
- package/templates/component-recipes/separator.md +124 -0
- package/templates/component-recipes/skeleton.md +186 -0
- package/templates/component-recipes/slider.md +114 -0
- package/templates/component-recipes/spinner.md +225 -0
- package/templates/component-recipes/switch.md +100 -0
- package/templates/component-recipes/table.md +161 -0
- package/templates/component-recipes/tabs.md +145 -0
- package/templates/component-recipes/textarea.md +234 -0
- package/templates/component-recipes/toast.md +209 -0
- package/templates/component-recipes/toggle-group.md +216 -0
- package/templates/component-recipes/tooltip.md +115 -0
- package/templates/hooks/triggers.d/styling.json +23 -0
- package/templates/skills/styling.md +173 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Table Component Recipe
|
|
2
|
+
|
|
3
|
+
## Structure
|
|
4
|
+
- Wrapper with horizontal scroll for responsive
|
|
5
|
+
- Table, Header, Body, Row, Head cell, Data cell
|
|
6
|
+
- Support for sorting indicators
|
|
7
|
+
- Optional row selection/hover states
|
|
8
|
+
|
|
9
|
+
## Tailwind Classes
|
|
10
|
+
|
|
11
|
+
### Wrapper
|
|
12
|
+
```
|
|
13
|
+
relative w-full overflow-auto
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
### Table
|
|
17
|
+
```
|
|
18
|
+
w-full caption-bottom text-sm
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Header
|
|
22
|
+
```
|
|
23
|
+
[&_tr]:border-b border-border
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Body
|
|
27
|
+
```
|
|
28
|
+
[&_tr:last-child]:border-0
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Row
|
|
32
|
+
```
|
|
33
|
+
border-b border-border transition-colors
|
|
34
|
+
hover:bg-muted/50
|
|
35
|
+
data-[state=selected]:bg-muted
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Head Cell (th)
|
|
39
|
+
```
|
|
40
|
+
h-10 px-2 text-left align-middle font-medium text-muted-foreground
|
|
41
|
+
[&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Data Cell (td)
|
|
45
|
+
```
|
|
46
|
+
p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Caption
|
|
50
|
+
```
|
|
51
|
+
mt-4 text-sm text-muted-foreground
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Sortable Header
|
|
55
|
+
```
|
|
56
|
+
cursor-pointer select-none hover:text-foreground
|
|
57
|
+
[&_svg]:ml-2 [&_svg]:h-4 [&_svg]:w-4
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Props Interface
|
|
61
|
+
```typescript
|
|
62
|
+
interface TableProps extends React.HTMLAttributes<HTMLTableElement> {}
|
|
63
|
+
|
|
64
|
+
interface TableHeaderProps extends React.HTMLAttributes<HTMLTableSectionElement> {}
|
|
65
|
+
|
|
66
|
+
interface TableBodyProps extends React.HTMLAttributes<HTMLTableSectionElement> {}
|
|
67
|
+
|
|
68
|
+
interface TableRowProps extends React.HTMLAttributes<HTMLTableRowElement> {
|
|
69
|
+
selected?: boolean
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface TableHeadProps extends React.ThHTMLAttributes<HTMLTableCellElement> {
|
|
73
|
+
sortable?: boolean
|
|
74
|
+
sortDirection?: 'asc' | 'desc' | null
|
|
75
|
+
onSort?: () => void
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface TableCellProps extends React.TdHTMLAttributes<HTMLTableCellElement> {}
|
|
79
|
+
|
|
80
|
+
interface TableCaptionProps extends React.HTMLAttributes<HTMLTableCaptionElement> {}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Do
|
|
84
|
+
- Use semantic table elements
|
|
85
|
+
- Include hover states for rows
|
|
86
|
+
- Support horizontal scroll for wide tables
|
|
87
|
+
- Use muted colors for headers
|
|
88
|
+
- Align numbers/dates right, text left
|
|
89
|
+
|
|
90
|
+
## Don't
|
|
91
|
+
- Hardcode colors
|
|
92
|
+
- Use divs for table layout
|
|
93
|
+
- Forget responsive scroll wrapper
|
|
94
|
+
- Skip border definition
|
|
95
|
+
|
|
96
|
+
## Example
|
|
97
|
+
```tsx
|
|
98
|
+
import { cn } from '@/lib/utils'
|
|
99
|
+
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'
|
|
100
|
+
|
|
101
|
+
const Table = ({ className, ...props }) => (
|
|
102
|
+
<div className="relative w-full overflow-auto">
|
|
103
|
+
<table className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const TableHeader = ({ className, ...props }) => (
|
|
108
|
+
<thead className={cn('[&_tr]:border-b border-border', className)} {...props} />
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
const TableBody = ({ className, ...props }) => (
|
|
112
|
+
<tbody className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
const TableRow = ({ className, selected, ...props }) => (
|
|
116
|
+
<tr
|
|
117
|
+
className={cn(
|
|
118
|
+
'border-b border-border transition-colors hover:bg-muted/50',
|
|
119
|
+
selected && 'bg-muted',
|
|
120
|
+
className
|
|
121
|
+
)}
|
|
122
|
+
data-state={selected ? 'selected' : undefined}
|
|
123
|
+
{...props}
|
|
124
|
+
/>
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
const TableHead = ({ className, sortable, sortDirection, onSort, children, ...props }) => (
|
|
128
|
+
<th
|
|
129
|
+
className={cn(
|
|
130
|
+
'h-10 px-2 text-left align-middle font-medium text-muted-foreground',
|
|
131
|
+
'[&:has([role=checkbox])]:pr-0',
|
|
132
|
+
sortable && 'cursor-pointer select-none hover:text-foreground',
|
|
133
|
+
className
|
|
134
|
+
)}
|
|
135
|
+
onClick={sortable ? onSort : undefined}
|
|
136
|
+
{...props}
|
|
137
|
+
>
|
|
138
|
+
<div className="flex items-center">
|
|
139
|
+
{children}
|
|
140
|
+
{sortable && (
|
|
141
|
+
<span className="ml-2">
|
|
142
|
+
{sortDirection === 'asc' && <ArrowUp className="h-4 w-4" />}
|
|
143
|
+
{sortDirection === 'desc' && <ArrowDown className="h-4 w-4" />}
|
|
144
|
+
{!sortDirection && <ArrowUpDown className="h-4 w-4 opacity-50" />}
|
|
145
|
+
</span>
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
</th>
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
const TableCell = ({ className, ...props }) => (
|
|
152
|
+
<td
|
|
153
|
+
className={cn('p-2 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
|
154
|
+
{...props}
|
|
155
|
+
/>
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
const TableCaption = ({ className, ...props }) => (
|
|
159
|
+
<caption className={cn('mt-4 text-sm text-muted-foreground', className)} {...props} />
|
|
160
|
+
)
|
|
161
|
+
```
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Tabs Component Recipe
|
|
2
|
+
|
|
3
|
+
## Structure
|
|
4
|
+
- Tab list container with tab triggers
|
|
5
|
+
- Tab content panels (one per tab)
|
|
6
|
+
- Support horizontal and vertical orientations
|
|
7
|
+
- Keyboard navigation between tabs
|
|
8
|
+
|
|
9
|
+
## Tailwind Classes
|
|
10
|
+
|
|
11
|
+
### Tab List
|
|
12
|
+
```
|
|
13
|
+
inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
### Tab Trigger
|
|
17
|
+
```
|
|
18
|
+
inline-flex items-center justify-center whitespace-nowrap {tokens.radius} px-3 py-1
|
|
19
|
+
text-sm font-medium ring-offset-background
|
|
20
|
+
transition-all
|
|
21
|
+
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2
|
|
22
|
+
disabled:pointer-events-none disabled:opacity-50
|
|
23
|
+
data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:{tokens.shadow}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Tab Content
|
|
27
|
+
```
|
|
28
|
+
mt-2 ring-offset-background
|
|
29
|
+
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Alternative: Underline Style
|
|
33
|
+
```
|
|
34
|
+
Tab List:
|
|
35
|
+
border-b border-border
|
|
36
|
+
|
|
37
|
+
Tab Trigger:
|
|
38
|
+
border-b-2 border-transparent pb-3 pt-2
|
|
39
|
+
data-[state=active]:border-primary data-[state=active]:text-foreground
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Alternative: Pills Style
|
|
43
|
+
```
|
|
44
|
+
Tab List:
|
|
45
|
+
flex gap-2
|
|
46
|
+
|
|
47
|
+
Tab Trigger:
|
|
48
|
+
rounded-full px-4 py-2
|
|
49
|
+
data-[state=active]:bg-primary data-[state=active]:text-primary-foreground
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Props Interface
|
|
53
|
+
```typescript
|
|
54
|
+
interface TabsProps {
|
|
55
|
+
defaultValue?: string
|
|
56
|
+
value?: string
|
|
57
|
+
onValueChange?: (value: string) => void
|
|
58
|
+
orientation?: 'horizontal' | 'vertical'
|
|
59
|
+
children: React.ReactNode
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface TabsListProps {
|
|
63
|
+
className?: string
|
|
64
|
+
children: React.ReactNode
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface TabsTriggerProps {
|
|
68
|
+
value: string
|
|
69
|
+
disabled?: boolean
|
|
70
|
+
children: React.ReactNode
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface TabsContentProps {
|
|
74
|
+
value: string
|
|
75
|
+
forceMount?: boolean
|
|
76
|
+
children: React.ReactNode
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Do
|
|
81
|
+
- Use Radix Tabs for accessibility
|
|
82
|
+
- Support keyboard navigation (arrow keys)
|
|
83
|
+
- Include focus ring for triggers
|
|
84
|
+
- Use subtle background for active state
|
|
85
|
+
|
|
86
|
+
## Don't
|
|
87
|
+
- Hardcode colors
|
|
88
|
+
- Skip focus indicators
|
|
89
|
+
- Use tabs for navigation (use nav links)
|
|
90
|
+
- Forget disabled state styling
|
|
91
|
+
|
|
92
|
+
## Example
|
|
93
|
+
```tsx
|
|
94
|
+
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
|
95
|
+
import { cn } from '@/lib/utils'
|
|
96
|
+
|
|
97
|
+
const Tabs = TabsPrimitive.Root
|
|
98
|
+
|
|
99
|
+
const TabsList = ({ className, ...props }) => (
|
|
100
|
+
<TabsPrimitive.List
|
|
101
|
+
className={cn(
|
|
102
|
+
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
|
|
103
|
+
className
|
|
104
|
+
)}
|
|
105
|
+
{...props}
|
|
106
|
+
/>
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
const TabsTrigger = ({ className, ...props }) => (
|
|
110
|
+
<TabsPrimitive.Trigger
|
|
111
|
+
className={cn(
|
|
112
|
+
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1',
|
|
113
|
+
'text-sm font-medium ring-offset-background transition-all',
|
|
114
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
|
|
115
|
+
'disabled:pointer-events-none disabled:opacity-50',
|
|
116
|
+
'data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
|
117
|
+
className
|
|
118
|
+
)}
|
|
119
|
+
{...props}
|
|
120
|
+
/>
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const TabsContent = ({ className, ...props }) => (
|
|
124
|
+
<TabsPrimitive.Content
|
|
125
|
+
className={cn(
|
|
126
|
+
'mt-2 ring-offset-background',
|
|
127
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
|
|
128
|
+
className
|
|
129
|
+
)}
|
|
130
|
+
{...props}
|
|
131
|
+
/>
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
// Usage
|
|
135
|
+
<Tabs defaultValue="account">
|
|
136
|
+
<TabsList>
|
|
137
|
+
<TabsTrigger value="account">Account</TabsTrigger>
|
|
138
|
+
<TabsTrigger value="password">Password</TabsTrigger>
|
|
139
|
+
<TabsTrigger value="team">Team</TabsTrigger>
|
|
140
|
+
</TabsList>
|
|
141
|
+
<TabsContent value="account">Account settings...</TabsContent>
|
|
142
|
+
<TabsContent value="password">Password settings...</TabsContent>
|
|
143
|
+
<TabsContent value="team">Team settings...</TabsContent>
|
|
144
|
+
</Tabs>
|
|
145
|
+
```
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# Textarea Component Recipe
|
|
2
|
+
|
|
3
|
+
## Structure
|
|
4
|
+
- Multi-line text input
|
|
5
|
+
- Support for auto-resize
|
|
6
|
+
- Character count option
|
|
7
|
+
- Error state
|
|
8
|
+
- Disabled state
|
|
9
|
+
|
|
10
|
+
## Tailwind Classes
|
|
11
|
+
|
|
12
|
+
### Base
|
|
13
|
+
```
|
|
14
|
+
flex min-h-[60px] w-full {tokens.radius} border border-border bg-background px-3 py-2
|
|
15
|
+
text-sm text-foreground placeholder:text-muted-foreground
|
|
16
|
+
transition-colors
|
|
17
|
+
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2
|
|
18
|
+
disabled:cursor-not-allowed disabled:opacity-50
|
|
19
|
+
resize-none
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### With Resize
|
|
23
|
+
```
|
|
24
|
+
resize-y (vertical only)
|
|
25
|
+
resize (both directions)
|
|
26
|
+
resize-none (no resize - default for controlled height)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Sizes
|
|
30
|
+
```
|
|
31
|
+
sm: min-h-[40px] text-sm
|
|
32
|
+
md: min-h-[60px] text-sm (default)
|
|
33
|
+
lg: min-h-[80px] text-base
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Error State
|
|
37
|
+
```
|
|
38
|
+
border-destructive focus-visible:ring-destructive
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Character Count
|
|
42
|
+
```
|
|
43
|
+
Container: relative
|
|
44
|
+
Counter: absolute bottom-2 right-2 text-xs text-muted-foreground
|
|
45
|
+
Counter error: text-destructive
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### With Label
|
|
49
|
+
```
|
|
50
|
+
Container: space-y-2
|
|
51
|
+
Label: text-sm font-medium leading-none
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Props Interface
|
|
55
|
+
```typescript
|
|
56
|
+
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
|
57
|
+
error?: boolean
|
|
58
|
+
errorMessage?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface TextareaWithCountProps extends TextareaProps {
|
|
62
|
+
maxLength: number
|
|
63
|
+
showCount?: boolean
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface AutoResizeTextareaProps extends TextareaProps {
|
|
67
|
+
minRows?: number
|
|
68
|
+
maxRows?: number
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Auto-Resize Logic
|
|
73
|
+
```typescript
|
|
74
|
+
const adjustHeight = (textarea: HTMLTextAreaElement) => {
|
|
75
|
+
textarea.style.height = 'auto'
|
|
76
|
+
textarea.style.height = `${textarea.scrollHeight}px`
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Do
|
|
81
|
+
- Use for multi-line text (comments, descriptions, messages)
|
|
82
|
+
- Include placeholder with example format
|
|
83
|
+
- Show character count for limited inputs
|
|
84
|
+
- Support auto-resize for better UX
|
|
85
|
+
|
|
86
|
+
## Don't
|
|
87
|
+
- Hardcode colors
|
|
88
|
+
- Use for single-line inputs (use Input)
|
|
89
|
+
- Make too small (frustrating to type in)
|
|
90
|
+
- Forget to handle long content gracefully
|
|
91
|
+
|
|
92
|
+
## Example
|
|
93
|
+
```tsx
|
|
94
|
+
import { forwardRef, useRef, useEffect } from 'react'
|
|
95
|
+
import { cn } from '@/lib/utils'
|
|
96
|
+
|
|
97
|
+
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
98
|
+
({ className, error, ...props }, ref) => {
|
|
99
|
+
return (
|
|
100
|
+
<textarea
|
|
101
|
+
className={cn(
|
|
102
|
+
'flex min-h-[60px] w-full rounded-lg border border-border bg-background px-3 py-2',
|
|
103
|
+
'text-sm text-foreground placeholder:text-muted-foreground',
|
|
104
|
+
'transition-colors',
|
|
105
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
|
|
106
|
+
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
107
|
+
'resize-none',
|
|
108
|
+
error && 'border-destructive focus-visible:ring-destructive',
|
|
109
|
+
className
|
|
110
|
+
)}
|
|
111
|
+
ref={ref}
|
|
112
|
+
{...props}
|
|
113
|
+
/>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
Textarea.displayName = 'Textarea'
|
|
119
|
+
|
|
120
|
+
// With character count
|
|
121
|
+
const TextareaWithCount = forwardRef<HTMLTextAreaElement, TextareaWithCountProps>(
|
|
122
|
+
({ maxLength, showCount = true, value, className, ...props }, ref) => {
|
|
123
|
+
const count = String(value || '').length
|
|
124
|
+
const isOverLimit = count > maxLength
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div className="relative">
|
|
128
|
+
<Textarea
|
|
129
|
+
ref={ref}
|
|
130
|
+
value={value}
|
|
131
|
+
maxLength={maxLength}
|
|
132
|
+
className={cn(showCount && 'pb-6', className)}
|
|
133
|
+
{...props}
|
|
134
|
+
/>
|
|
135
|
+
{showCount && (
|
|
136
|
+
<span
|
|
137
|
+
className={cn(
|
|
138
|
+
'absolute bottom-2 right-3 text-xs',
|
|
139
|
+
isOverLimit ? 'text-destructive' : 'text-muted-foreground'
|
|
140
|
+
)}
|
|
141
|
+
>
|
|
142
|
+
{count}/{maxLength}
|
|
143
|
+
</span>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
TextareaWithCount.displayName = 'TextareaWithCount'
|
|
151
|
+
|
|
152
|
+
// Auto-resize textarea
|
|
153
|
+
const AutoResizeTextarea = forwardRef<HTMLTextAreaElement, AutoResizeTextareaProps>(
|
|
154
|
+
({ minRows = 2, maxRows = 10, onChange, className, ...props }, ref) => {
|
|
155
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
156
|
+
|
|
157
|
+
const adjustHeight = () => {
|
|
158
|
+
const textarea = textareaRef.current
|
|
159
|
+
if (!textarea) return
|
|
160
|
+
|
|
161
|
+
textarea.style.height = 'auto'
|
|
162
|
+
const lineHeight = parseInt(getComputedStyle(textarea).lineHeight)
|
|
163
|
+
const minHeight = lineHeight * minRows
|
|
164
|
+
const maxHeight = lineHeight * maxRows
|
|
165
|
+
|
|
166
|
+
const newHeight = Math.min(Math.max(textarea.scrollHeight, minHeight), maxHeight)
|
|
167
|
+
textarea.style.height = `${newHeight}px`
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
adjustHeight()
|
|
172
|
+
}, [props.value])
|
|
173
|
+
|
|
174
|
+
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
175
|
+
adjustHeight()
|
|
176
|
+
onChange?.(e)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<Textarea
|
|
181
|
+
ref={(node) => {
|
|
182
|
+
textareaRef.current = node
|
|
183
|
+
if (typeof ref === 'function') ref(node)
|
|
184
|
+
else if (ref) ref.current = node
|
|
185
|
+
}}
|
|
186
|
+
onChange={handleChange}
|
|
187
|
+
className={cn('resize-none overflow-hidden', className)}
|
|
188
|
+
rows={minRows}
|
|
189
|
+
{...props}
|
|
190
|
+
/>
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
AutoResizeTextarea.displayName = 'AutoResizeTextarea'
|
|
196
|
+
|
|
197
|
+
// With label and error
|
|
198
|
+
const TextareaField = ({ label, error, errorMessage, id, ...props }) => (
|
|
199
|
+
<div className="space-y-2">
|
|
200
|
+
{label && (
|
|
201
|
+
<label htmlFor={id} className="text-sm font-medium leading-none">
|
|
202
|
+
{label}
|
|
203
|
+
</label>
|
|
204
|
+
)}
|
|
205
|
+
<Textarea id={id} error={error} {...props} />
|
|
206
|
+
{errorMessage && (
|
|
207
|
+
<p className="text-sm text-destructive">{errorMessage}</p>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
// Usage examples
|
|
213
|
+
<Textarea placeholder="Type your message here..." />
|
|
214
|
+
|
|
215
|
+
<TextareaWithCount
|
|
216
|
+
maxLength={280}
|
|
217
|
+
placeholder="What's happening?"
|
|
218
|
+
value={message}
|
|
219
|
+
onChange={(e) => setMessage(e.target.value)}
|
|
220
|
+
/>
|
|
221
|
+
|
|
222
|
+
<AutoResizeTextarea
|
|
223
|
+
placeholder="Write your comment..."
|
|
224
|
+
minRows={2}
|
|
225
|
+
maxRows={6}
|
|
226
|
+
/>
|
|
227
|
+
|
|
228
|
+
<TextareaField
|
|
229
|
+
label="Description"
|
|
230
|
+
placeholder="Describe your project..."
|
|
231
|
+
error={!!errors.description}
|
|
232
|
+
errorMessage={errors.description?.message}
|
|
233
|
+
/>
|
|
234
|
+
```
|