Skip to content

Commit d985323

Browse files
committed
feat: add component textarea
1 parent e224497 commit d985323

File tree

17 files changed

+393
-3
lines changed

17 files changed

+393
-3
lines changed

packages/ui/package.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,7 @@
1111
"main": "./dist/index.js",
1212
"module": "./dist/index.js",
1313
"types": "./dist/index.d.ts",
14-
"files": [
15-
"dist"
16-
],
14+
"files": ["dist"],
1715
"scripts": {
1816
"build": "pnpm run pgk:prod && pnpm run build:components && pnpm run registry",
1917
"build:components": "tsdown",
@@ -47,6 +45,7 @@
4745
"@radix-ui/react-toggle": "^1.1.9",
4846
"@radix-ui/react-toggle-group": "^1.1.10",
4947
"@radix-ui/react-tooltip": "^1.2.7",
48+
"@radix-ui/react-use-controllable-state": "^1.2.2",
5049
"@soybean-react-ui/tailwind-plugin": "workspace:*",
5150
"clsx": "2.1.1",
5251
"cmdk": "^1.1.1",
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
'use client';
2+
3+
import { useControllableState } from '@radix-ui/react-use-controllable-state';
4+
import { type ChangeEvent, forwardRef } from 'react';
5+
6+
import TextareaContent from './TextareaContent';
7+
import TextareaCount from './TextareaCount';
8+
import TextareaRoot from './TextareaRoot';
9+
import type { TextareaProps } from './types';
10+
11+
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>((props, ref) => {
12+
const {
13+
classNames,
14+
countGraphemes,
15+
countRender,
16+
defaultValue,
17+
maxLength,
18+
onChange,
19+
onTextChange,
20+
showCount,
21+
size,
22+
value,
23+
...rest
24+
} = props;
25+
26+
const [_value, setValue] = useControllableState({
27+
caller: 'textarea',
28+
defaultProp: defaultValue,
29+
onChange: onTextChange,
30+
prop: value
31+
});
32+
33+
function handleChange(e: ChangeEvent<HTMLTextAreaElement>) {
34+
setValue(e.target.value);
35+
onChange?.(e);
36+
}
37+
38+
return (
39+
<TextareaRoot
40+
className={classNames?.root}
41+
size={size}
42+
>
43+
<TextareaContent
44+
className={classNames?.content}
45+
maxLength={maxLength}
46+
ref={ref}
47+
size={size}
48+
value={_value}
49+
onChange={handleChange}
50+
{...rest}
51+
/>
52+
53+
{showCount && (
54+
<TextareaCount
55+
className={classNames?.count}
56+
countGraphemes={countGraphemes}
57+
maxLength={maxLength}
58+
size={size}
59+
value={_value}
60+
>
61+
{countRender}
62+
</TextareaCount>
63+
)}
64+
</TextareaRoot>
65+
);
66+
});
67+
68+
Textarea.displayName = 'Textarea';
69+
70+
export default Textarea;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { forwardRef } from 'react';
2+
3+
import { cn } from '@/lib/utils';
4+
5+
import { textareaVariants } from './textarea-variants';
6+
import type { TextareaContentProps } from './types';
7+
8+
const TextareaContent = forwardRef<HTMLTextAreaElement, TextareaContentProps>((props, ref) => {
9+
const { className, size, ...rest } = props;
10+
11+
const { content } = textareaVariants({ size });
12+
13+
const mergedCls = cn(content(), className);
14+
15+
return (
16+
<textarea
17+
className={mergedCls}
18+
data-size={size}
19+
data-slot="textarea"
20+
ref={ref}
21+
{...rest}
22+
/>
23+
);
24+
});
25+
26+
TextareaContent.displayName = 'TextareaContent';
27+
28+
export default TextareaContent;
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { cn } from '@/lib/utils';
2+
3+
import { textareaVariants } from './textarea-variants';
4+
import type { TextareaCountProps } from './types';
5+
6+
const TextareaCount = (props: TextareaCountProps) => {
7+
const { children, className, countGraphemes, maxLength, size, value, ...rest } = props;
8+
9+
const { count } = textareaVariants({ size });
10+
11+
const mergedCls = cn(count(), className);
12+
13+
const getCount = () => {
14+
if (!value) {
15+
return 0;
16+
}
17+
18+
return countGraphemes?.(value) || String(value).length;
19+
};
20+
21+
const countWithMaxLength = () => {
22+
const _count = getCount();
23+
if (maxLength) {
24+
return `${_count} / ${maxLength}`;
25+
}
26+
27+
return String(_count);
28+
};
29+
30+
const text = countWithMaxLength();
31+
32+
return (
33+
<div
34+
{...rest}
35+
className={mergedCls}
36+
data-size={size}
37+
data-slot="textarea-count"
38+
>
39+
{children?.(text) || text}
40+
</div>
41+
);
42+
};
43+
44+
export default TextareaCount;
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { cn } from '@/lib/utils';
2+
3+
import { textareaVariants } from './textarea-variants';
4+
import type { TextareaRootProps } from './types';
5+
6+
const TextareaRoot = (props: TextareaRootProps) => {
7+
const { className, size, ...rest } = props;
8+
9+
const { root } = textareaVariants({ size });
10+
11+
const mergedCls = cn(root(), className);
12+
13+
return (
14+
<div
15+
{...rest}
16+
className={mergedCls}
17+
data-size={size}
18+
data-slot="textarea-root"
19+
/>
20+
);
21+
};
22+
23+
export default TextareaRoot;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export { default as Textarea } from './Textarea';
2+
3+
export { default as TextareaContent } from './TextareaContent';
4+
export { default as TextareaCount } from './TextareaCount';
5+
export { default as TextareaRoot } from './TextareaRoot';
6+
7+
export * from './types';
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { tv } from 'tailwind-variants';
2+
3+
export const textareaVariants = tv({
4+
defaultVariants: {
5+
resize: 'vertical',
6+
size: 'md'
7+
},
8+
slots: {
9+
content: [
10+
`flex w-full rounded-md border border-solid border-input bg-background`,
11+
`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:ring-primary`,
12+
`disabled:cursor-not-allowed disabled:opacity-50`
13+
],
14+
count: 'absolute z-2 leading-none text-muted-foreground',
15+
root: 'relative'
16+
},
17+
variants: {
18+
size: {
19+
'2xl': {
20+
content: 'min-h-12 px-4 py-2 text-xl',
21+
count: 'text-xl right-4 bottom-3.5'
22+
},
23+
lg: {
24+
content: 'min-h-9 px-2.5 py-1 text-base',
25+
count: 'text-base right-3 bottom-2.5'
26+
},
27+
md: {
28+
content: 'min-h-8 px-2 py-1 text-sm',
29+
count: 'text-sm right-2.5 bottom-2'
30+
},
31+
sm: {
32+
content: 'min-h-7 px-1.75 py-1 text-xs',
33+
count: 'text-xs right-2 bottom-1.75'
34+
},
35+
xl: {
36+
content: 'min-h-10 px-3 py-1 text-lg',
37+
count: 'text-lg right-3.5 bottom-3'
38+
},
39+
xs: {
40+
content: 'min-h-6 px-1.5 py-1 text-2xs',
41+
count: 'text-2xs right-1.75 bottom-1.5'
42+
}
43+
}
44+
}
45+
});
46+
47+
export type TextareaSlots = keyof typeof textareaVariants.slots;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { ReactNode } from 'react';
2+
3+
import type { BaseComponentProps, ClassValue } from '@/types/other';
4+
5+
import type { TextareaSlots } from './textarea-variants';
6+
7+
export interface TextareaContentProps extends BaseComponentProps<'textarea'> {}
8+
9+
export type TextareaClassNames = Partial<Record<TextareaSlots, ClassValue>>;
10+
11+
export interface TextareaCountProps
12+
extends Omit<BaseComponentProps<'div'>, 'children'>,
13+
Pick<TextareaContentProps, 'maxLength' | 'value'> {
14+
children?: (count: string) => ReactNode;
15+
countGraphemes?: (input: TextareaContentProps['value']) => number;
16+
}
17+
18+
export interface TextareaRootProps extends BaseComponentProps<'div'> {}
19+
20+
export interface TextareaProps extends TextareaContentProps, Pick<TextareaCountProps, 'countGraphemes'> {
21+
classNames?: TextareaClassNames;
22+
countRender?: (count: string) => ReactNode;
23+
onTextChange?: (value: TextareaContentProps['value']) => void;
24+
showCount?: boolean;
25+
}

packages/ui/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ export * from './components/switch';
6262

6363
export * from './components/tabs';
6464

65+
export * from './components/textarea';
66+
6567
export * from './components/toggle';
6668

6769
export * from './components/toggle-group';

playground/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
},
4040
"dependencies": {
4141
"embla-carousel-autoplay": "8.6.0",
42+
"grapheme-splitter": "^1.0.4",
4243
"lucide-react": "0.525.0",
4344
"next": "15.3.4",
4445
"next-themes": "0.4.6",

0 commit comments

Comments
 (0)