Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/shared/images/eye-off.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/shared/images/eye.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
113 changes: 92 additions & 21 deletions src/shared/ui/TextField/TextField.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,45 +2,116 @@ import type { Meta, StoryObj } from "@storybook/react";
import { TextField } from "./TextField";

const meta: Meta<typeof TextField> = {
title: "Components/TextField",
title: "COMPONENTS/TextField",
component: TextField,
tags: ["autodocs"],
argTypes: {
width: { control: "text" },
height: { control: "text" },
placeholder: { control: "text" },
error: { control: "boolean" },
fieldSize: {
control: { type: "radio" },
options: ["sm", "md", "lg"],
description: "Input height, text size, and icon size",
defaultValue: "lg",
},
widthSize: {
control: { type: "radio" },
options: ["long", "short"],
description: "Input width type",
defaultValue: "long",
},
isError: {
control: { type: "boolean" },
description: "Show error border (red)",
defaultValue: false,
},
isHiddenable: {
control: { type: "boolean" },
description: "Enable eye toggle button for password fields",
defaultValue: false,
},
placeholder: {
control: { type: "text" },
description: "Placeholder text",
},
value: {
control: { type: "text" },
description: "Controlled value (if provided)",
},
},
};

export default meta;

type Story = StoryObj<typeof TextField>;

// 기본 (Uncontrolled)
export const Default: Story = {
args: {
width: "400px",
height: "70px",
placeholder: "Enter your text",
placeholder: "내용을 입력해주세요.",
fieldSize: "lg",
widthSize: "long",
},
};

// 에러 상태
export const Error: Story = {
args: {
placeholder: "에러 상태",
fieldSize: "md",
widthSize: "long",
isError: true,
},
};

export const Focused: Story = {
// 비밀번호 입력 (숨김 가능)
export const PasswordHiddenable: Story = {
args: {
width: "400px",
height: "70px",
placeholder: "Type something...",
placeholder: "비밀번호 입력",
fieldSize: "md",
widthSize: "long",
isHiddenable: true,
},
play: async ({ canvasElement }) => {
const input = canvasElement.querySelector("input") as HTMLInputElement;
input?.focus();
};

//Long size
export const Small: Story = {
args: {
placeholder: "Small",
fieldSize: "sm",
widthSize: "long",
},
};

export const Error: Story = {
//Medium Size
export const Medium: Story = {
args: {
placeholder: "Medium",
fieldSize: "md",
widthSize: "long",
},
};

//Large Size
export const Large: Story = {
args: {
placeholder: "Large",
fieldSize: "lg",
widthSize: "long",
},
};

// Long Width
export const Long: Story = {
args: {
placeholder: "long width",
fieldSize: "lg",
widthSize: "long",
},
};

// Short width
export const Short: Story = {
args: {
placeholder: "This field has an error",
error: true,
width: "400px",
height: "70px",
placeholder: "short width",
fieldSize: "lg",
widthSize: "short",
},
};
113 changes: 88 additions & 25 deletions src/shared/ui/TextField/TextField.tsx
Original file line number Diff line number Diff line change
@@ -1,49 +1,112 @@
"use client"
"use client";

import React, { InputHTMLAttributes, useState } from "react";
import cn from "@/shared/lib/cn";
import Image from "next/image";
import eyeIcon from "@/shared/images/eye.svg"
import eyeOffIcon from "@/shared/images/eye-off.svg"

interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
width?: string | number;
height?: string | number;
error?: boolean;
type TextFieldSize = "sm" | "md" | "lg";
type TextFieldWidthSize = "long" | "short";

interface TextFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "size"> {
fieldSize?: TextFieldSize;
widthSize?: TextFieldWidthSize;
isError?: boolean;
isHiddenable?: boolean;
}

export const TextField = (props: TextFieldProps) => {
const { width, height, error, className, ...rest} = props;
const styleMap: Record<
TextFieldSize,
{
widthMap: Record<TextFieldWidthSize, number>;
height: number;
textSize: string;
iconSize: { w: number; h: number };
}
> = {
lg: {
widthMap: { long: 640, short: 400 },
height: 70,
textSize: "text-[16px] placeholder:text-[16px]",
iconSize: { w: 24, h: 24 },
},
md: {
widthMap: { long: 440, short: 360 },
height: 55,
textSize: "text-[14px] placeholder:text-[14px]",
iconSize: { w: 22, h: 22 },
},
sm: {
widthMap: { long: 335, short: 335 },
height: 55,
textSize: "text-[14px] placeholder:text-[14px]",
iconSize: { w: 22, h: 22 },
},
};

export const TextField = ({
fieldSize = "lg",
widthSize = "long",
isError = false,
isHiddenable = false,
className,
...rest
} : TextFieldProps) => {
const [text, setText] = useState("");
const [focused, setFocused] = useState(false);
const [hidden, setHidden] = useState(isHiddenable); //비밀번호 시 숨겨진 상태로 시작

const [text, setText] = useState("");
const [focused, setFocused] = useState(false);
const { widthMap, height, textSize, iconSize} = styleMap[fieldSize];
const width = widthMap[widthSize];

return (
return (
<div
className={`
flex items-center rounded-lg transition-colors
bg-black-800
px-[20px] py-[23px]
${error ? "border border-red" : focused ? "border border-blue" : "border border-gray-400"}
${className}
`}
style={{width, height}}
className={cn(
"flex items-center rounded-lg transition-colors bg-black-800 px-[20px] py-[23px]",
isError
? "border border-red"
: focused
? "border border-blue"
: "border border-gray-400",
)}
style={{ width, height }}
>
<input
{...rest}
value={text}
type={hidden ? "password" : "text"}
value={rest.value ?? text} //부모에서 내용 control 가능!
onChange={(e) => {
setText(e.target.value);
props.onChange?.(e);
rest.onChange?.(e); //text 수정 시 handling
}}
className={`flex-1 bg-transparent outline-none text-[20px] ${
"text-white"
}`}
className={cn(
textSize,
"flex-1 bg-transparent outline-none text-white",
className
)}
onFocus={(e) => {
setFocused(true);
props.onFocus?.(e);
}}
onBlur={(e) => {
setFocused(false);
props.onBlur?.(e);
}}
/>

{isHiddenable && (
<button
type="button"
onClick={() => setHidden((prev) => !prev)}
className="ml-2"
>
<Image
src={hidden ? eyeIcon : eyeOffIcon}
alt={hidden ? "hidden" : "visible"}
width={iconSize.w}
height={iconSize.h}
/>
</button>
)}
</div>
);
};