Skip to content

Commit 2641385

Browse files
committed
feat: Playground 예시 수정
2 parents 1b65d5b + 725844a commit 2641385

7 files changed

Lines changed: 180 additions & 1 deletion

File tree

src/pages/playground.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { BannerCard } from '@shared/ui/BannerCard/BannerCard';
22
import { Button } from '@shared/ui/Button/Button';
33
import { Card } from '@shared/ui/Card/Card';
44
import { ChatBubble } from '@shared/ui/ChatBubble';
5+
import { ChatInput } from '@shared/ui/ChatInput';
56
import { Checkbox } from '@shared/ui/Checkbox/Checkbox';
67
import { FavoriteButton } from '@shared/ui/FavoriteButton';
78
import { Header } from '@shared/ui/Header/Header';
@@ -16,6 +17,7 @@ import { useState } from 'react';
1617
export default function Playground() {
1718
const [radioValue, setRadioValue] = useState('option1');
1819
const [isModalOpen, setIsModalOpen] = useState(false);
20+
const [chatMessage, setChatMessage] = useState('');
1921

2022
return (
2123
<div className="flex flex-col gap-6 p-8">
@@ -137,6 +139,16 @@ export default function Playground() {
137139
/>
138140
</div>
139141
</section>
142+
{/* Chat Input */}
143+
<section className="flex flex-col gap-4">
144+
<h2 className="typo-body-1">ChatInput</h2>
145+
<ChatInput
146+
placeholder="무슨 견적을 원하시나요?"
147+
value={chatMessage}
148+
onChange={setChatMessage}
149+
onSend={() => {}}
150+
/>
151+
</section>
140152
{/* Favorite Button */}
141153
<section className="flex flex-col gap-4">
142154
<h2 className="typo-body-1">FavoriteButton</h2>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { default as CaretDownMdIcon } from './caret-down-md.svg?react';
22
export { default as CheckIcon } from './check.svg?react';
33
export { default as DoneIcon } from './done.svg?react';
4+
export { default as SendIcon } from './send.svg?react';
45
export { default as SearchMagnifyingGlassIcon } from './search-magnifying-glass.svg?react';
56
export { default as HeartIcon } from './heart-outline.svg?react';
Lines changed: 11 additions & 0 deletions
Loading

src/shared/assets/icons/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
export { CaretDownMdIcon, CheckIcon, DoneIcon, HeartIcon, SearchMagnifyingGlassIcon } from './common';
1+
export { CaretDownMdIcon, CheckIcon, DoneIcon, HeartIcon, SendIcon, SearchMagnifyingGlassIcon } from './common';
22
export { GearIcon, ShoppingIcon, SellIcon } from './banner';
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { SendIcon } from '@shared/assets/icons';
2+
import { useLayoutEffect, useRef, useState, type ChangeEventHandler, type KeyboardEventHandler } from 'react';
3+
import { chatInputVariants } from './ChatInput.variants';
4+
5+
const TEXTAREA_LINE_HEIGHT = 24;
6+
const TEXTAREA_MAX_LINES = 4;
7+
8+
export interface ChatInputProps {
9+
placeholder?: string;
10+
onSend: (message: string) => void;
11+
value?: string;
12+
onChange?: (value: string) => void;
13+
}
14+
15+
export const ChatInput = ({ placeholder, onSend, value, onChange }: ChatInputProps) => {
16+
const [internalMessage, setInternalMessage] = useState('');
17+
const [isFocused, setFocused] = useState(false);
18+
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
19+
20+
const isControlled = value !== undefined;
21+
const message = isControlled ? value : internalMessage;
22+
const trimmedMessage = message.trim();
23+
const isFilled = trimmedMessage.length > 0;
24+
25+
const styles = chatInputVariants({
26+
state: isFocused ? (isFilled ? 'filled' : 'focused') : isFilled ? 'filled' : 'default',
27+
sendState: isFilled ? 'active' : 'inactive',
28+
});
29+
30+
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = (event) => {
31+
const nextValue = event.target.value;
32+
if (isControlled) {
33+
onChange?.(nextValue);
34+
} else {
35+
setInternalMessage(nextValue);
36+
}
37+
};
38+
39+
const handleSend = () => {
40+
if (!trimmedMessage) {
41+
return;
42+
}
43+
44+
onSend(trimmedMessage);
45+
46+
if (isControlled) {
47+
onChange?.('');
48+
} else {
49+
setInternalMessage('');
50+
}
51+
};
52+
53+
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (event) => {
54+
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
55+
event.preventDefault();
56+
handleSend();
57+
}
58+
};
59+
60+
useLayoutEffect(() => {
61+
const element = textareaRef.current;
62+
if (!element) {
63+
return;
64+
}
65+
66+
element.style.height = 'auto';
67+
const maxHeight = TEXTAREA_LINE_HEIGHT * TEXTAREA_MAX_LINES;
68+
const nextHeight = Math.min(element.scrollHeight, maxHeight);
69+
element.style.height = `${nextHeight}px`;
70+
element.style.overflowY = element.scrollHeight > maxHeight ? 'auto' : 'hidden';
71+
}, [message]);
72+
73+
return (
74+
<div className={styles.root()}>
75+
<div className={styles.wrapper()}>
76+
<textarea
77+
ref={textareaRef}
78+
value={message}
79+
onFocus={() => setFocused(true)}
80+
onBlur={() => setFocused(false)}
81+
onChange={handleChange}
82+
className={styles.input()}
83+
placeholder={placeholder}
84+
aria-label={placeholder ?? 'Chat input'}
85+
onKeyDown={handleKeyDown}
86+
rows={1}
87+
/>
88+
<button type="button" className={styles.button()} onClick={handleSend} disabled={!isFilled} aria-label="Send">
89+
<SendIcon className={styles.icon()} aria-hidden="true" />
90+
</button>
91+
</div>
92+
</div>
93+
);
94+
};
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { tv } from 'tailwind-variants';
2+
3+
export const chatInputVariants = tv({
4+
slots: {
5+
root: ['flex w-full'],
6+
wrapper: [
7+
'flex items-center',
8+
'w-[1200px] max-w-full min-h-[60px]',
9+
'px-[22px] py-[11px]',
10+
'gap-[16px]',
11+
'rounded-[var(--radius-l)]',
12+
'border-[1px] border-[var(--color-green-700)]',
13+
'bg-white',
14+
],
15+
input: [
16+
'w-full bg-transparent outline-none resize-none',
17+
'typo-body-1',
18+
'placeholder:text-[var(--color-gray-400)]',
19+
],
20+
button: ['flex items-center justify-center', 'w-[38px] h-[38px]', 'rounded-full', 'transition-colors', 'shrink-0'],
21+
icon: ['w-[38px] h-[38px]', '[&_.send-circle]:transition-colors', '[&_.send-arrow]:transition-colors'],
22+
},
23+
24+
variants: {
25+
state: {
26+
default: {
27+
input: ['text-[var(--color-gray-500)]'],
28+
},
29+
focused: {
30+
input: ['text-[var(--color-black)]'],
31+
},
32+
filled: {
33+
input: ['text-[var(--color-black)]'],
34+
},
35+
},
36+
sendState: {
37+
inactive: {
38+
button: ['cursor-not-allowed'],
39+
icon: [
40+
'[&_.send-circle]:fill-white',
41+
'[&_.send-circle]:stroke-[var(--color-gray-400)]',
42+
'[&_.send-arrow]:stroke-[var(--color-gray-400)]',
43+
],
44+
},
45+
active: {
46+
icon: [
47+
'[&_.send-circle]:fill-[var(--color-green-500)]',
48+
'[&_.send-circle]:stroke-[var(--color-black)]',
49+
'[&_.send-arrow]:stroke-[var(--color-black)]',
50+
],
51+
},
52+
},
53+
},
54+
55+
defaultVariants: {
56+
state: 'default',
57+
sendState: 'inactive',
58+
},
59+
});

src/shared/ui/ChatInput/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { ChatInput } from './ChatInput';
2+
export type { ChatInputProps } from './ChatInput';

0 commit comments

Comments
 (0)