Skip to content
Open
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
24 changes: 14 additions & 10 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import { Button } from "@/components/Button/Default/Button";
import { ArrowLeft } from "@phosphor-icons/react";
"use client";

import { IconPhosphor } from "@/components/Icon/Icon";
import { Input } from "@/components/Input/Default/Input";

export default function Home() {
return (
<div>
<Button
text="Button"
variant="tinted"
color="danger"
disabled
icon={<ArrowLeft />}
iconPosition="start"
onlyIcon
<Input
placeholder="Enter text here"
icon={<IconPhosphor name="Chats" />}
status="success"
rightComponent={
<IconPhosphor
name="ArrowRight"
className="text-gray-500 cursor-pointer"
/>
}
/>
</div>
);
Expand Down
15 changes: 13 additions & 2 deletions src/components/Icon/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,33 @@ import { IconProps } from "@phosphor-icons/react";

export type IconPhosphorName = keyof typeof PhosphorIcons;

type IconPhosphorProps = {
export type IconPhosphorProps = {
name: IconPhosphorName;
size?: number;
color?: string;
className?: string;
weight?: "fill" | "light" | "thin" | "regular" | "bold";
};

export const IconPhosphor: React.FC<IconPhosphorProps> = ({
name,
size = 24,
color = "currentColor",
className,
weight,
}) => {
const SpecificIcon = PhosphorIcons[name] as React.ElementType<IconProps>;

if (!SpecificIcon) {
return null;
}

return <SpecificIcon size={size} color={color} />;
return (
<SpecificIcon
className={`pointer-events-none ${className}`}
size={size}
color={color}
weight={weight}
/>
);
};
34 changes: 34 additions & 0 deletions src/components/Input/Default/Input.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Meta, StoryObj } from "@storybook/react";
import { Input } from "./Input";
import { IconPhosphor } from "@/components/Icon/Icon";

const meta: Meta<typeof Input> = {
title: "Components/Input",
component: Input,
argTypes: {
type: {
control: "select",
options: ["text", "password", "number"],
},
status: {
control: "select",
options: ["success", "error", undefined],
},

placeholder: {
control: "text",
},
},
args: {
type: "text",
status: undefined,

icon: <IconPhosphor name="User" weight="fill" />,
rightComponent: <IconPhosphor name="Acorn" />,

placeholder: "Placeholder",
},
};
export default meta;
type Story = StoryObj<typeof Input>;
export const Default: Story = {};
62 changes: 62 additions & 0 deletions src/components/Input/Default/Input.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { cleanup, render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, afterEach } from "vitest";
import "@testing-library/jest-dom/vitest";
import { Input } from "./Input";

const makeSut = (props: React.ComponentProps<typeof Input>) => {
render(<Input {...props} />);
};

describe("Input", () => {
afterEach(() => {
cleanup();
});

it("renders the input with default props", () => {
makeSut({ placeholder: "Type something..." });
const input = screen.getByPlaceholderText("Type something...");
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute("type", "text");
});

it("renders the input with an icon passed via prop", () => {
const TestIcon = () => <span>Icon</span>;
makeSut({ placeholder: "Type something...", icon: <TestIcon /> });
const icon = screen.getByText("Icon");
expect(icon).toBeInTheDocument();
});

it("renders the 'success' status icon when status is set to 'success'", () => {
makeSut({ placeholder: "Type something...", status: "success" });
const container =
screen.getByPlaceholderText("Type something...").parentElement;
expect(container?.querySelector(".text-success-500")).toBeInTheDocument();
});

it("renders the 'error' status icon when status is set to 'error'", () => {
makeSut({ placeholder: "Type something...", status: "error" });
const container =
screen.getByPlaceholderText("Type something...").parentElement;
expect(container?.querySelector(".text-danger-500")).toBeInTheDocument();
});

it("displays the 'rigthComponent' when the input is focused", () => {
const RightComponent = () => <span>Right</span>;
makeSut({
placeholder: "Type something...",
rightComponent: <RightComponent />,
});
const input = screen.getByPlaceholderText("Type something...");
expect(screen.queryByText("Right")).not.toBeInTheDocument();
fireEvent.focus(input);
expect(screen.getByText("Right")).toBeInTheDocument();
});

it("focuses the input when clicking on the container", () => {
makeSut({ placeholder: "Type something..." });
const input = screen.getByPlaceholderText("Type something...");
const container = input.parentElement;
fireEvent.click(container!);
expect(input).toHaveFocus();
});
});
72 changes: 72 additions & 0 deletions src/components/Input/Default/Input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { IconPhosphor } from "@/components/Icon/Icon";
import { cn } from "@/lib/utils";
import * as React from "react";
import { InputProps } from "./Input.types";

const Input = React.forwardRef<HTMLInputElement, InputProps>(
(
{ className, rightComponent, type = "text", icon, status, ...props },
ref
) => {
const [focused, setFocused] = React.useState(false);

const renderStatusIcon = () => {
if (status === "success") {
return (
<IconPhosphor
name="CheckCircle"
className="text-success-500"
weight="fill"
/>
);
}
if (status === "error") {
return (
<IconPhosphor
name="Warning"
className="text-danger-500"
weight="fill"
/>
);
}
return null;
};

return (
<div
className={cn(
"flex items-center border px-4.5 py-3 w-full gap-[12px] cursor-text",
"hover:ring-1 hover:ring-primary-200 focus-within:ring-1 focus-within:ring-primary-500",
{
"border-green-200 bg-green-50": status === "success",
"border-red-200 bg-red-50": status === "error",
"border-gray-100": !status,
}
)}
onClick={(e) => {
const input = e.currentTarget.querySelector("input");
input?.focus();
}}
>
{icon}

<input
type={type}
ref={ref}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
className={cn(
"flex-1 outline-none bg-transparent text-gray-500 placeholder:text-body-lg-400",
className
)}
{...props}
/>
{focused && rightComponent ? rightComponent : renderStatusIcon()}
</div>
);
}
);

Input.displayName = "Input";

export { Input };
6 changes: 6 additions & 0 deletions src/components/Input/Default/Input.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export type Status = "success" | "error" | undefined;
export interface InputProps extends React.ComponentProps<"input"> {
icon?: React.ReactNode;
rightComponent?: React.ReactNode;
status?: Status;
}