Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: update search bar #400

Merged
merged 1 commit into from
Jul 26, 2024
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
127 changes: 37 additions & 90 deletions components/shared/SearchBar/SearchBar.tsx
Original file line number Diff line number Diff line change
@@ -1,110 +1,57 @@
import type { ClassValue } from "clsx";
import { FormEvent, ForwardedRef } from "react";
import { cn } from "@/lib/utils";

import Button from "../Button";
import Input, { ChangeHandler } from "../Input";

type SubmitHandler = (value: string, event: FormEvent<HTMLFormElement>) => void;
import { ReactNode, useState } from "react";
import Icon from "../Icon/v2/Icon";

interface SearchBarProps {
/** The current value of the input */
value: string;

/** The placeholder text displayed in the search bar when it is empty */
placeholder?: string;

/** For button text content */
buttonText?: string;

/** If `true`, the button will be in loading mode */
loading?: boolean;

/** If `true`, the input will receive autofocus when rendered. */
autoFocus?: boolean;

/** The ref object used to access the underlying HTMLInputElement */
inputRef?: ForwardedRef<HTMLInputElement>;

/** The ref object used to access the underlying HTMLButtonElement */
buttonRef?: ForwardedRef<HTMLButtonElement>;

/** For root class name */
className?: ClassValue;

/** For input wrapper class name */
inputWrapperClassName?: ClassValue;

/** For input class name */
inputClassName?: ClassValue;
/** An optional React node that will be rendered on the left side of the input field */
leftSlot?: ReactNode;

/** For button class name */
buttonClassName?: ClassValue;

/**
* Callback function that is called when the value of the input changes.
* @param value - The new value of the input.
* @param event - The event object associated with the change event.
*/
onChange?: ChangeHandler;
/** An optional React node that will be rendered on the submit button */
buttonSlot?: ReactNode;

/**
* Callback function triggered when the user submits the search.
* @param value - The current input value of the search bar.
* @param event - The form submission event.
*/
onSubmit?: SubmitHandler;
onSubmit?: (value: string) => void;
}

export const SearchBar = ({
value,
placeholder = "請輸入你想搜尋的遊戲,玩家名稱,帖子關鍵字...",
buttonText = "搜索",
loading,
autoFocus,
className,
inputRef,
buttonRef,
inputWrapperClassName: inputWrapperClassNameProps,
inputClassName: inputClassNameProps,
buttonClassName: buttonClassNameProps,
onChange,
placeholder = "在此輸入今天想玩的遊戲",
leftSlot,
buttonSlot = (
<Icon
name="search"
className="stroke-primary-100 w-6 h-6 pointer-events-none"
/>
),
onSubmit,
}: SearchBarProps) => {
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
onSubmit?.(value, e);
};

const rootClassName = cn(
"group flex rounded-[10px] border border-[#2D2D2E] shadow focus-within:border-[#2F88FF]/80 focus-within:shadow-[#2F88FF]/40 transition-[box-shadow,border]",
className
);

const inputWrapperClassName = cn("flex-1", inputWrapperClassNameProps);

const inputClassName = cn("rounded-r-none border-0", inputClassNameProps);

const buttonClassName = cn(
"leading-none bg-[#2D2D2E] rounded-l-none rounded-r shadow-none group-focus-within:bg-[#2F88FF]/80 active:group-focus-within:bg-[#2173DD]",
buttonClassNameProps
);
const [value, setValue] = useState("");

return (
<form onSubmit={handleSubmit} className={rootClassName}>
<Input
ref={inputRef}
role="search"
value={value}
placeholder={placeholder}
autoFocus={autoFocus}
className={inputWrapperClassName}
inputClassName={inputClassName}
onChange={onChange}
/>
<Button ref={buttonRef} className={buttonClassName} loading={loading}>
{buttonText}
</Button>
</form>
<div className="p-px gradient-purple rounded-full max-w-lg w-full">
<div className="body-bg rounded-full">
<div className="relative flex p-1 bg-white/8 rounded-full w-full">
{leftSlot}
<input
role="search"
className="py-2.5 px-4 leading-normal rounded-full bg-white/8 flex-1 text-primary-200"
placeholder={placeholder}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<button
type="button"
className="absolute right-3 p-2.5"
onClick={() => onSubmit?.(value)}
>
{buttonSlot}
</button>
</div>
</div>
</div>
);
};
31 changes: 16 additions & 15 deletions components/shared/SearchBar/searchBar.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useArgs } from "@storybook/preview-api";

import type { ChangeHandler } from "../Input";
import SearchBar from ".";

const meta: Meta<typeof SearchBar> = {
Expand All @@ -10,33 +8,36 @@ const meta: Meta<typeof SearchBar> = {
tags: ["autodocs"],
decorators: [
(Story, ctx) => {
const [, setArgs] = useArgs<typeof ctx.args>();

const onChange: ChangeHandler = (value, event) => {
ctx.args.onChange?.(value, event);

// Check if the component is controlled
if (typeof ctx.args.value !== undefined) {
setArgs({ value });
}
const onSubmit = (value: string) => {
ctx.args.onSubmit?.(value);
};

return (
<div className="flex justify-center">
<div className="max-w-[546px] w-full">
<Story args={{ ...ctx.args, onChange }} />
<Story args={{ ...ctx.args, onSubmit }} />
</div>
</div>
);
},
],
argTypes: {
onChange: {
type: "function",
},
onSubmit: {
type: "function",
},
leftSlot: {
control: { type: "select" },
options: ["Empty", "Button"],
defaultValue: "Button",
mapping: {
Empty: null,
Button: (
<button type="button" className="pl-5 pr-2.5 px-4 text-primary-300">
類型
</button>
),
},
},
},
};

Expand Down
106 changes: 7 additions & 99 deletions components/shared/SearchBar/searchBar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,122 +1,30 @@
import React, { useState } from "react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import React from "react";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";

import type { ChangeHandler } from "../Input";
import SearchBar from ".";

describe("SearchBar", () => {
it("should renders input component with correct value", async () => {
const initValue = "test";
const mockChange = jest.fn();

const ControllerComponent = () => {
const [value, setValue] = useState(initValue);
const handleChange: ChangeHandler = (v, e) => {
setValue(v);
mockChange(v, e);
};

return <SearchBar value={value} onChange={handleChange} />;
};
const mockSubmit = jest.fn();

render(<ControllerComponent />);
render(<SearchBar onSubmit={mockSubmit} buttonSlot="submit" />);

const inputElement = screen.getByRole<HTMLInputElement>("search");
const buttonElement = screen.getByRole<HTMLInputElement>("button");

expect(inputElement).toBeInTheDocument();
expect(inputElement.value).toBe(initValue);

const typingValue = "search";

await act(async () => {
await userEvent.type(inputElement, typingValue);
});

expect(mockChange).toHaveBeenCalledTimes(typingValue.length);
expect(mockChange).toHaveBeenLastCalledWith(
initValue + typingValue,
expect.any(Object)
);
expect(inputElement.value).toBe(initValue + typingValue);
});

it("should call the onSubmit event handler with the correct value and event object", async () => {
const initValue = "test-submit";
const mockSubmit = jest.fn();

const ControllerComponent = () => {
const [value, setValue] = useState(initValue);

return (
<SearchBar
value={value}
onChange={setValue}
onSubmit={mockSubmit}
buttonText="查詢"
/>
);
};

render(<ControllerComponent />);

const inputElement = screen.getByRole<HTMLInputElement>("search");
const buttonElement = screen.getByRole("button", {
name: "查詢",
});

expect(buttonElement).toBeInTheDocument();

await act(async () => {
await userEvent.click(buttonElement);
});

expect(mockSubmit).toHaveBeenCalledTimes(1);
expect(mockSubmit).toHaveBeenLastCalledWith(initValue, expect.any(Object));

const typingValue = "-complete";

await act(async () => {
await userEvent.type(inputElement, typingValue);
await userEvent.click(buttonElement);
});

expect(mockSubmit).toHaveBeenCalledTimes(2);
expect(mockSubmit).toHaveBeenLastCalledWith(
initValue + typingValue,
expect.any(Object)
);
});

it("should renders correct class name", async () => {
const rootClassName = "test-root";
const buttonClassName = "test-button";
const inputClassName = "test-input";
const inputWrapperClassName = "test-input-wrapper";

const { container } = render(
<SearchBar
value="test"
autoFocus
buttonText="search"
className={rootClassName}
buttonClassName={buttonClassName}
inputClassName={inputClassName}
inputWrapperClassName={inputWrapperClassName}
/>
);

const rootElement = container.querySelector("form");
const inputElement = container.querySelector("input");
const inputWrapperElement = container.querySelector("div");
const buttonElement = screen.getByRole("button", {
name: "search",
});

expect(rootElement).toHaveClass(rootClassName);
expect(buttonElement).toHaveClass(buttonClassName);
expect(inputElement).toHaveClass(inputClassName);
expect(inputWrapperElement).toHaveClass(inputWrapperClassName);
expect(mockSubmit).toHaveBeenLastCalledWith(typingValue);
expect(inputElement.value).toBe(typingValue);
});
});
Loading