Skip to content

Commit

Permalink
feat: update search bar (#400)
Browse files Browse the repository at this point in the history
  • Loading branch information
JohnsonMao authored Jul 26, 2024
1 parent 5b1109e commit 56751f2
Show file tree
Hide file tree
Showing 3 changed files with 60 additions and 204 deletions.
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);
});
});

0 comments on commit 56751f2

Please sign in to comment.