diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index 3b9122d..2c767c3 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -13,7 +13,7 @@ export default function MainLayout({ children }: { children: React.ReactNode })
-
{children}
+
{children}
); diff --git a/src/app/(main)/search/page.tsx b/src/app/(main)/search/page.tsx index 54b6578..a29578c 100644 --- a/src/app/(main)/search/page.tsx +++ b/src/app/(main)/search/page.tsx @@ -1,7 +1,153 @@ +"use client"; + +import { useState } from "react"; +import Title from "@/components/search/SearchTitle"; +import { TextField } from "@/components/ui/TextField"; +import { TextArea } from "@/components/ui/TextArea"; +import { AICreationButton } from "@/components/ui/AI_Creation_Button"; +import { ElementList, type Element } from "@/components/search/ElementList/ElementList"; +import { Checklist } from "@/components/search/Checklist"; +import { Footer } from "@/components/search/Footer"; +import { PatentImportModal } from "@/components/search/PatentImportModal"; +import { Button } from "@/components/ui/Button"; + export default function SearchPage() { + const [elements, setElements] = useState([ + { id: crypto.randomUUID(), name: "", description: "" }, + ]); + const [isLoading, setIsLoading] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); + + const handleAdd = () => { + setElements((prev) => [...prev, { id: crypto.randomUUID(), name: "", description: "" }]); + }; + + const handleDelete = (id: string) => { + setElements((prev) => prev.filter((el) => el.id !== id)); + }; + + const handleChange = (id: string, field: "name" | "description", value: string) => { + setElements((prev) => prev.map((el) => (el.id === id ? { ...el, [field]: value } : el))); + }; + + const handleAICreate = () => { + setIsLoading(true); + setTimeout(() => { + setElements([ + { + id: crypto.randomUUID(), + name: "생분해성 베이스 수지", + description: "PLA·PBAT 블렌드 폴리에스터", + }, + { + id: crypto.randomUUID(), + name: "표면개질 나노 충전제", + description: "실란 처리된 무기 나노입자", + }, + { + id: crypto.randomUUID(), + name: "무용제 수계 분산 공정", + description: "유기용제 없이 수계 분산으로 코팅층 형성", + }, + { + id: crypto.randomUUID(), + name: "UV 경화 기교", + description: "자외선 경화형 가교제에 의한 표면 가교", + }, + ]); + setIsLoading(false); + }, 2000); + }; + return ( - <> -
- +
+
+
+ + <Button variant="secondary" size="sm" onClick={() => setIsModalOpen(true)}> + 특허 번호로 불러오기 + </Button> + </div> + + <div className="flex flex-col gap-4 pl-10"> + <TextField + labelSize={17} + label="발명의 명칭" + placeholder="EX) 생분해성 고분자 코팅 조성물" + gap={1.5} + /> + + <div className="flex flex-row gap-3"> + <TextField + labelSize={17} + label="기술 분야" + placeholder="EX) 고분자 화학 코팅" + gap={1.5} + /> + <TextField labelSize={17} label="IPC 분류" placeholder="EX) C09D 5/00" gap={1.5} /> + </div> + + <TextArea + labelSize={17} + label="핵심 기술 설명" + placeholder="발명의 핵심 구성과 작동 방식을 간단히 설명해주세요" + rows={4} + /> + </div> + </div> + + <div className="flex flex-col gap-7"> + <Title stepnum={2} title="출원인 정보" /> + + <div className="flex flex-row gap-3 pl-10"> + <TextField + labelSize={17} + label="사명 (선택)" + placeholder="EX) 그린폴리머(주)" + gap={1.5} + /> + <TextField + labelSize={17} + label="의뢰인 (선택)" + placeholder="담당자 또는 발명자 성명을 입력해주세요" + gap={1.5} + /> + </div> + </div> + + <div className="flex flex-col gap-5"> + <div className="flex flex-row items-center justify-between"> + <Title + stepnum={3} + title="구성요소 분석" + label="청구항을 구성요소 단위로 분해해 판단의 정밀성을 높힙니다." + /> + + <AICreationButton onClick={handleAICreate} /> + </div> + + <div className="overflow-hidden border-y border-outline-default ml-10"> + <ElementList + elements={elements} + isLoading={isLoading} + onAdd={handleAdd} + onDelete={handleDelete} + onChange={handleChange} + /> + </div> + + <Checklist /> + </div> + + <Footer /> + + {isModalOpen && ( + <PatentImportModal + initialPatentNumber="" + onClose={() => setIsModalOpen(false)} + onSubmit={() => setIsModalOpen(false)} + /> + )} + </div> ); } diff --git a/src/app/globals.css b/src/app/globals.css index 4422335..c7d9617 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -42,6 +42,7 @@ /* Primary */ --color-bg-primary-tint: #f5f9ff; /* Primary-120 */ --color-bg-primary-light: #eef5ff; /* Primary-110 */ + --color-bg-elementlist: #E4EFFF; /* Primary-100 */ /* Primary-light */ --color-bg-primary: #4478f0; /* Primary-50 */ @@ -68,6 +69,8 @@ --color-icon-neutral-subtle: #909ba6; /* Gray-50 */ --color-icon-primary-default: #4478F0; + --color-icon-primary-emphasize: #2152E5; + /* Stroke */ --color-stroke-divider: #f2f6f9; /* Gray-90 */ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5c3d1c7..6423be9 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -88,7 +88,7 @@ export default function RootLayout({ }>) { return ( <html lang="ko" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}> - <body className="min-h-full flex flex-col">{children}</body> + <body className="h-full overflow-hidden">{children}</body> </html> ); } diff --git a/src/components/search/Checklist.tsx b/src/components/search/Checklist.tsx new file mode 100644 index 0000000..8a1ddca --- /dev/null +++ b/src/components/search/Checklist.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useState } from "react"; +import { Checkbox } from "@/components/searchlist/Checkbox"; + +const ITEMS = ["미공개 기술", "관련 수치데이터 보유", "누락 정보 없음"] as const; + +export function Checklist() { + const [checked, setChecked] = useState<Record<string, boolean>>({}); + + return ( + <div className="flex items-center gap-8 pl-10"> + {ITEMS.map((label, index) => { + const uniqueId = `checklist-${index}`; + + return ( + <div key={label} className="flex items-center gap-1"> + <Checkbox + id={uniqueId} + checked={!!checked[label]} + onChange={(e) => setChecked((prev) => ({ ...prev, [label]: e.target.checked }))} + /> + + <label htmlFor={uniqueId} className="cursor-pointer text-body-15 text-title-secondary"> + {label} + </label> + </div> + ); + })} + </div> + ); +} diff --git a/src/components/search/ElementList/ElementList.tsx b/src/components/search/ElementList/ElementList.tsx new file mode 100644 index 0000000..dd6a478 --- /dev/null +++ b/src/components/search/ElementList/ElementList.tsx @@ -0,0 +1,64 @@ +"use client"; + +import PlusIcon from "@/components/icons/icon-plus.svg"; +import { LoadingIndicator } from "./LoadingIndicator"; +import { ElementRow } from "./ElementRow"; + +export interface Element { + id: string; + name: string; + description: string; +} + +interface ElementListProps { + elements: Element[]; + isLoading?: boolean; + onAdd: () => void; + onDelete: (id: string) => void; + onChange: (id: string, field: "name" | "description", value: string) => void; +} + +export function ElementList({ + elements, + isLoading = false, + onAdd, + onDelete, + onChange, +}: ElementListProps) { + return ( + <div className="w-full"> + <div className="grid grid-cols-[1fr_1fr_auto] items-center gap-4 bg-bg-neutral-hover px-4 py-2.5"> + <span className="text-label-15 text-body-disabled">발명 구성요소</span> + <span className="text-label-15 text-body-disabled">설명</span> + <div className="w-11" /> + </div> + + {isLoading ? ( + <LoadingIndicator label="AI가 발명 정보를 분석해 구성요소를 추출하고 있습니다..." /> + ) : ( + <> + {elements.map((el, index) => ( + <ElementRow + key={el.id} + index={index} + name={el.name} + description={el.description} + onDelete={() => onDelete(el.id)} + onChangeName={(value) => onChange(el.id, "name", value)} + onChangeDescription={(value) => onChange(el.id, "description", value)} + /> + ))} + + <button + type="button" + onClick={onAdd} + className="flex w-full items-center gap-1 px-4 py-3 text-label-17 text-primary-default transition-opacity cursor-pointer bg-bg-surface hover:bg-bg-neutral-hover active:bg-bg-neutral-subtle" + > + <PlusIcon className=" m-2 h-5 w-5 text-icon-primary-emphasize [&_path]:fill-current" /> + 구성요소 추가 + </button> + </> + )} + </div> + ); +} diff --git a/src/components/search/ElementList/ElementRow.tsx b/src/components/search/ElementList/ElementRow.tsx new file mode 100644 index 0000000..df1b33f --- /dev/null +++ b/src/components/search/ElementList/ElementRow.tsx @@ -0,0 +1,46 @@ +import TrashIcon from "@/components/icons/icon-trashcan.svg"; +import { ElementTitle } from "./ElementTitle"; + +interface ElementRowProps { + index: number; + name: string; + description: string; + onDelete: () => void; + onChangeName: (value: string) => void; + onChangeDescription: (value: string) => void; +} + +export function ElementRow({ + index, + name, + description, + onDelete, + onChangeName, + onChangeDescription, +}: ElementRowProps) { + return ( + <div className="grid grid-cols-[1fr_1fr_auto] items-center gap-4 px-4 py-3"> + <ElementTitle index={index} value={name} onChange={onChangeName} /> + + <textarea + value={description} + onChange={(e) => { + onChangeDescription(e.target.value); + e.target.style.height = "auto"; + e.target.style.height = `${e.target.scrollHeight}px`; + }} + placeholder="구성요소 설명" + rows={1} + className="w-full resize-none overflow-hidden bg-transparent text-label-17 text-body-secondary placeholder:text-caption-label placeholder:text-label-17 focus:outline-none" + /> + + <button + type="button" + onClick={onDelete} + className="p-2.5 text-icon-neutral-subtle transition-colors hover:text-error-default" + > + <TrashIcon className="h-6 w-6 [&_path]:fill-current" /> + </button> + </div> + ); +} diff --git a/src/components/search/ElementList/ElementTitle.tsx b/src/components/search/ElementList/ElementTitle.tsx new file mode 100644 index 0000000..e845874 --- /dev/null +++ b/src/components/search/ElementList/ElementTitle.tsx @@ -0,0 +1,23 @@ +interface ElementTitleProps { + index: number; + value: string; + onChange: (value: string) => void; +} + +export function ElementTitle({ index, value, onChange }: ElementTitleProps) { + return ( + <div className="flex min-w-0 w-full items-center gap-3"> + <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-bg-elementlist text-label-emphasis-15 text-primary-sub"> + {String.fromCharCode(65 + index)} + </span> + + <input + type="text" + value={value} + onChange={(e) => onChange(e.target.value)} + placeholder="구성요소 명칭" + className="w-full bg-transparent text-title-secondary text-label-emphasis-17 placeholder:text-caption-label placeholder:text-label-emphasis-17 focus:outline-none" + /> + </div> + ); +} diff --git a/src/components/search/ElementList/LoadingIndicator.tsx b/src/components/search/ElementList/LoadingIndicator.tsx new file mode 100644 index 0000000..ad4cf15 --- /dev/null +++ b/src/components/search/ElementList/LoadingIndicator.tsx @@ -0,0 +1,12 @@ +type LoadingIndicatorProps = { + label: string; +}; + +export function LoadingIndicator({ label }: LoadingIndicatorProps) { + return ( + <div className="flex flex-col items-center justify-center gap-4 py-12"> + <div className="h-9 w-9 animate-spin rounded-full border-3 border-icon-primary-default border-t-transparent" /> + <span className="text-label-17 text-caption-label">{label}</span> + </div> + ); +} diff --git a/src/components/search/Footer.tsx b/src/components/search/Footer.tsx new file mode 100644 index 0000000..7ecfb08 --- /dev/null +++ b/src/components/search/Footer.tsx @@ -0,0 +1,18 @@ +import { Button } from "@/components/ui/Button"; +import { ResultCountButton } from "./ResultCountButton"; + +export function Footer() { + return ( + <div className="flex pt-6 pb-7 pl-10 items-center justify-between"> + <div className="flex items-center gap-6 text-label-17 text-title-secondary"> + <span>탐색 결과 개수</span> + <ResultCountButton /> + </div> + + <Button size="sm" disabled> + 탐색 시작 + </Button> + {/* api 연동 이후 disabled 해제 관련 조건 추가 예정 */} + </div> + ); +} diff --git a/src/components/search/PatentImportModal.tsx b/src/components/search/PatentImportModal.tsx new file mode 100644 index 0000000..8042940 --- /dev/null +++ b/src/components/search/PatentImportModal.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useState } from "react"; +import CancelIcon from "@/components/icons/icon-cancel.svg"; +import { TextField } from "@/components/ui/TextField"; +import { Button } from "@/components/ui/Button"; + +interface PatentImportModalProps { + initialPatentNumber: string; + onClose: () => void; + onSubmit: (data: { patentNumber: string }) => void; +} + +export function PatentImportModal({ onClose, onSubmit }: PatentImportModalProps) { + const [patentNumber, setPatentNumber] = useState(""); + + return ( + <div + className="fixed inset-0 z-50 flex items-center justify-center bg-scrim-2" + onClick={onClose} + > + <div + className="w-138.5 p-6 flex flex-col gap-8 rounded-lg bg-bg-surface shadow-[0px_1px_6px_0px_rgba(144,155,165,0.36)]" + onClick={(e) => e.stopPropagation()} + > + <div className="flex items-center justify-between"> + <h2 className="text-title-emphasis-17 text-title-primary">특허번호로 기술 불러오기</h2> + <button + type="button" + onClick={onClose} + aria-label="닫기" + className="flex items-center justify-center cursor-pointer" + > + <CancelIcon className="h-6 w-6" aria-hidden /> + </button> + </div> + + <TextField + label="특허번호" + value={patentNumber} + onChange={(e) => setPatentNumber(e.target.value)} + placeholder="특허번호를 입력해주세요" + /> + + <Button onClick={() => onSubmit({ patentNumber })}>기술 불러오기</Button> + </div> + </div> + ); +} diff --git a/src/components/search/ResultCountButton.tsx b/src/components/search/ResultCountButton.tsx new file mode 100644 index 0000000..259369b --- /dev/null +++ b/src/components/search/ResultCountButton.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useState } from "react"; +import MinusIcon from "@/components/icons/icon-minus.svg"; +import PlusIcon from "@/components/icons/icon-plus.svg"; + +export function ResultCountButton() { + const [count, setCount] = useState(10); + + return ( + <div className="flex items-center overflow-hidden rounded-sm bg-bg-surface border border-outline-sub text-icon-neutral-subtle"> + <button + type="button" + onClick={() => setCount((n) => Math.max(1, n - 1))} + className="flex h-12 w-11 px-3 py-2 items-center justify-center transition-colors hover:bg-bg-neutral-hover active:bg-bg-neutral-subtle" + > + <MinusIcon className="h-5 w-5 [&_path]:fill-current" /> + </button> + + <span className="flex w-16.5 h-12 py-2 items-center justify-center border-x border-outline-sub text-label-emphasis-17 text-title-primary"> + {count}개 + </span> + + <button + type="button" + onClick={() => setCount((n) => n + 1)} + className="flex h-12 w-11 px-3 py-2 items-center justify-center transition-colors hover:bg-bg-neutral-hover active:bg-bg-neutral-subtle" + > + <PlusIcon className="h-5 w-5 [&_path]:fill-current" /> + </button> + </div> + ); +} diff --git a/src/components/search/SearchTitle.tsx b/src/components/search/SearchTitle.tsx new file mode 100644 index 0000000..37138b1 --- /dev/null +++ b/src/components/search/SearchTitle.tsx @@ -0,0 +1,20 @@ +type TitleProps = { + stepnum: number; + title: string; + label?: string; +}; + +export default function Title({ stepnum, title, label }: TitleProps) { + return ( + <div className="flex flex-row gap-4 items-center"> + <div className="w-6 h-6 flex items-center justify-center rounded-sm bg-bg-primary-hover text-label-emphasis-15 text-inverse-on-primary"> + {stepnum} + </div> + + <div className="flex flex-row gap-2 items-center"> + <p className="text-title-primary text-title-22">{title}</p> + <p className="text-caption-label text-label-15">{label}</p> + </div> + </div> + ); +} diff --git a/src/components/searchlist/Checkbox.tsx b/src/components/searchlist/Checkbox.tsx index 0cb9db5..0460b12 100644 --- a/src/components/searchlist/Checkbox.tsx +++ b/src/components/searchlist/Checkbox.tsx @@ -30,7 +30,7 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>( return ( <label className={cn( - "inline-flex h-6 w-6 items-center justify-center", + "relative inline-flex h-6 w-6 items-center justify-center", disabled ? "cursor-not-allowed" : "cursor-pointer", className )} @@ -50,9 +50,9 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>( /> <span className={cn( - "flex h-4 w-4 items-center justify-center rounded-[1px] border-2", + "flex h-4 w-4 items-center justify-center rounded-[1px] border-[1.5px]", isFilled - ? "border-primary-default bg-primary-default" + ? "border-icon-primary-default bg-icon-primary-default rounded-[1px]" : "border-outline-selected bg-bg-surface" )} > diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index 1a30df4..63c99e9 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -19,20 +19,26 @@ export function Sidebar({ open, onToggle }: SidebarProps) { return ( <aside - className={`flex flex-col gap-5 px-3 py-5 border-r border-outline-sub transition-all duration-500 ${ - open ? "w-72" : "w-15" + className={`flex h-full flex-col gap-5 overflow-hidden border-r border-outline-sub py-5 transition-all duration-500 ${ + open ? "w-72 px-3" : "w-15 px-2" // 닫혔을 때 padding을 살짝 줄여주면 좁은 공간에서 밸런스가 더 좋습니다. }`} > - <div - className={`flex py-2 px-3 h-14 shrink-0 items-center ${open ? "justify-between" : "justify-center"}`} - > - {open && <IPXLogo width={57} height={20} />} - <button className="ml-auto cursor-pointer text-icon-neutral-default" onClick={onToggle}> + <div className={`flex h-14 shrink-0 items-center py-2 ${open ? "px-3" : "justify-center"}`}> + <div + className={`overflow-hidden transition-all ${open ? "w-14.25 opacity-100 duration-500 delay-200" : "w-0 opacity-0 duration-500"}`} + > + <IPXLogo width={57} height={20} className="shrink-0" /> + </div> + + <button + className={`cursor-pointer text-icon-neutral-default transition-transform duration-500 ${open ? "ml-auto" : ""}`} + onClick={onToggle} + > <IconViewSidebar width={20} height={20} className="fill-icon-neutral-default" /> </button> </div> - <nav className="flex flex-col gap-1 pb-5 border-b border-stroke-divider"> + <nav className="flex flex-col gap-1 border-b border-stroke-divider pb-5"> <SidebarNavItem href="/myhistory" icon={<MyHistory width={20} height={20} />} @@ -56,14 +62,13 @@ export function Sidebar({ open, onToggle }: SidebarProps) { /> </nav> - {open && ( - <div className="flex flex-1 flex-col gap-1.5 overflow-y-auto"> - <span className="px-3 text-label-13 text-title-primary">최근 탐색</span> - <PreviousSearchItem href="#" label="니켈 회수율을 높일 수 있는 습식제련 기..." /> - <PreviousSearchItem href="#" label="니켈 회수율을 높일 수 있는 습식제련 기..." /> - <PreviousSearchItem href="#" label="니켈 회수율을 높일 수 있는 습식제련 기..." /> - </div> //이후 api 연동시 map 이용해서 무한 스크롤 형식으로 변경 예정(지금은 위 3개만 봐주세용..) - )} + <div + className={`flex flex-1 flex-col gap-1.5 overflow-y-auto transition-opacity ${open ? "opacity-100 duration-300 delay-200" : "pointer-events-none opacity-0 duration-500"}`} + > + <span className="px-3 text-label-13 text-title-primary">최근 탐색</span> + <PreviousSearchItem href="#" label="니켈 회수율을 높일 수 있는 습식제련 기..." /> + {/* ... */} + </div> </aside> ); } diff --git a/src/components/sidebar/SidebarNavItem.tsx b/src/components/sidebar/SidebarNavItem.tsx index 4f242d2..985471d 100644 --- a/src/components/sidebar/SidebarNavItem.tsx +++ b/src/components/sidebar/SidebarNavItem.tsx @@ -3,7 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/cn"; const sidebarNavItemVariants = cva( - "flex w-full cursor-pointer items-center gap-2 px-3 py-2 transition-colors", + "flex w-full cursor-pointer items-center py-2 transition-colors", // gap-2와 px-3를 제거하여 아래에서 조건부로 제어합니다. { variants: { active: { @@ -34,26 +34,33 @@ export function SidebarNavItem({ return ( <Link href={href} - className={cn(sidebarNavItemVariants({ active }), !open && "justify-center", className)} + className={cn( + sidebarNavItemVariants({ active }), + // 열렸을 땐 패딩과 간격을 주고, 닫혔을 땐 정중앙 배치 + open ? "px-3 gap-2 justify-start" : "px-0 justify-center", + className + )} > <div className={cn( - "flex shrink-0 items-center justify-center", + "flex shrink-0 items-center justify-center transition-transform duration-500", active ? "text-icon-primary-default" : "text-body-primary" + // 불필요한 translate-x 제거됨 )} > {icon} </div> - {open && ( - <span - className={cn( - "text-label-15", - active === true ? "text-primary-default" : "text-body-primary" - )} - > - {label} - </span> - )} + <span + className={cn( + "overflow-hidden whitespace-nowrap text-label-15 transition-[max-width,opacity]", + active === true ? "text-primary-default" : "text-body-primary", + open + ? "max-w-40 opacity-100 duration-300 delay-200" + : "max-w-0 pointer-events-none opacity-0 duration-500" + )} + > + {label} + </span> </Link> ); } diff --git a/src/components/topbar/Topbar.tsx b/src/components/topbar/Topbar.tsx index 08b6097..425f7cc 100644 --- a/src/components/topbar/Topbar.tsx +++ b/src/components/topbar/Topbar.tsx @@ -3,7 +3,7 @@ import { usePathname } from "next/navigation"; const PAGE_LABELS: Record<string, string> = { - "/history": "내 활동 기록", + "/myhistory": "내 활동 기록", "/search": "선행기술 탐색", "/analysis": "기술 분석", }; diff --git a/src/components/ui/AI_Creation_Button.tsx b/src/components/ui/AI_Creation_Button.tsx new file mode 100644 index 0000000..6fcb045 --- /dev/null +++ b/src/components/ui/AI_Creation_Button.tsx @@ -0,0 +1,24 @@ +import { forwardRef } from "react"; +import { cn } from "@/lib/cn"; +import AICreationIcon from "@/components/icons/icon-aiCreation.svg"; + +export type AICreationButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>; + +export const AICreationButton = forwardRef<HTMLButtonElement, AICreationButtonProps>( + ({ className, type = "button", ...props }, ref) => ( + <button + ref={ref} + type={type} + className={cn( + "inline-flex cursor-pointer items-center gap-1 rounded-sm border border-stroke-primary bg-bg-surface py-2 pl-2 pr-3 text-label-15 text-primary-default transition-colors hover:bg-bg-neutral-hover active:bg-bg-neutral-subtle", + className + )} + {...props} + > + <AICreationIcon className="h-5 w-5 [&_path]:fill-icon-primary-emphasize" aria-hidden /> + AI 자동 생성 + </button> + ) +); + +AICreationButton.displayName = "AICreationButton"; diff --git a/src/components/ui/TextArea.tsx b/src/components/ui/TextArea.tsx new file mode 100644 index 0000000..4037bb5 --- /dev/null +++ b/src/components/ui/TextArea.tsx @@ -0,0 +1,52 @@ +import { useId } from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/cn"; + +const labelVariants = cva("flex items-center text-title-secondary", { + variants: { + labelSize: { + 13: "h-5 text-label-13", + 15: "h-5 text-label-15", + 17: "h-6 text-label-17", + }, + }, + defaultVariants: { labelSize: 13 }, +}); + +type TextAreaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement> & + VariantProps<typeof labelVariants> & { + label: string; + error?: string; + ref?: React.Ref<HTMLTextAreaElement>; + }; + +export function TextArea({ label, labelSize, error, className, id, ref, ...props }: TextAreaProps) { + const autoId = useId(); + const textareaId = id ?? autoId; + const errorId = `${textareaId}-error`; + + return ( + <div className="flex w-full flex-col items-start gap-1.5"> + <label htmlFor={textareaId} className={labelVariants({ labelSize })}> + {label} + </label> + <textarea + ref={ref} + id={textareaId} + aria-invalid={!!error} + aria-describedby={error ? errorId : undefined} + className={cn( + "h-27.5 w-full resize-none rounded-lg border border-outline-default bg-bg-surface p-4 text-body-17 text-body-primary placeholder:text-caption-label focus:border-stroke-primary focus:outline-none", + error && "border-stroke-error focus:border-stroke-error", + className + )} + {...props} + /> + {error && ( + <p id={errorId} className="flex h-5 items-center text-label-13 text-error-default"> + {error} + </p> + )} + </div> + ); +} diff --git a/src/components/ui/TextField.tsx b/src/components/ui/TextField.tsx index 3bed2e7..489ee65 100644 --- a/src/components/ui/TextField.tsx +++ b/src/components/ui/TextField.tsx @@ -1,20 +1,53 @@ import { useId } from "react"; +import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/cn"; -type TextFieldProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, "size"> & { - label: string; - error?: string; - ref?: React.Ref<HTMLInputElement>; -}; +const labelVariants = cva("flex items-center text-title-secondary", { + variants: { + labelSize: { + 13: "h-5 text-label-13", + 15: "h-5 text-label-15", + 17: "h-6 text-label-17", + }, + }, + defaultVariants: { labelSize: 13 }, +}); -export function TextField({ label, error, className, id, ref, ...props }: TextFieldProps) { +const wrapperVariants = cva("flex w-full flex-col items-start", { + variants: { + gap: { + 1: "gap-1", + 1.5: "gap-1.5", + }, + }, + defaultVariants: { gap: 1 }, +}); + +type TextFieldProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, "size"> & + VariantProps<typeof labelVariants> & + VariantProps<typeof wrapperVariants> & { + label: string; + error?: string; + ref?: React.Ref<HTMLInputElement>; + }; + +export function TextField({ + label, + labelSize, + gap, + error, + className, + id, + ref, + ...props +}: TextFieldProps) { const autoId = useId(); const inputId = id ?? autoId; const errorId = `${inputId}-error`; return ( - <div className="flex w-full flex-col items-start gap-1"> - <label htmlFor={inputId} className="flex h-5 items-center text-label-13 text-title-secondary"> + <div className={wrapperVariants({ gap })}> + <label htmlFor={inputId} className={labelVariants({ labelSize })}> {label} </label> <input