Skip to content
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
33c23f3
feat(ui): implement reusable SearchBar component
nancymuyeh Sep 17, 2025
dadff60
feat(ui): implement reusable SearchBar component
nancymuyeh Sep 17, 2025
02fdf91
feat(ui): implement reusable SearchBar component
nancymuyeh Sep 18, 2025
07f3c4a
feat(ui): implement reusable SearchBar component
nancymuyeh Sep 18, 2025
581d387
Merge branch 'main' into 31-design-and-implement-shared-reusable-sear…
nancymuyeh Sep 18, 2025
62a576d
fix(ui): mark SearchBar component prop as read-only
nancymuyeh Sep 18, 2025
0f3de59
Merge branch '31-design-and-implement-shared-reusable-search-bar-comp…
nancymuyeh Sep 18, 2025
d886154
fix(ui): increase test coverage
nancymuyeh Sep 18, 2025
b198f93
Merge branch 'main' into 31-design-and-implement-shared-reusable-sear…
nancymuyeh Sep 18, 2025
190f0b3
fix(ui): increase test coverage
nancymuyeh Sep 18, 2025
a24a3d9
fix(ui): refactor useSearchBar hook with extracted helper functions f…
nancymuyeh Sep 18, 2025
a74f693
fix(ui): refactor useSearchBar hook with extracted helper functions f…
nancymuyeh Sep 18, 2025
390b514
Update packages/ui/src/components/SearchBar/SearchBar.view.tsx
nancymuyeh Sep 18, 2025
7f2e481
fix(ui): refactor useSearchBar component for SonarQube compliance
nancymuyeh Sep 19, 2025
5f5396c
fix(ui): refactor useSearchBar component for SonarQube compliance
nancymuyeh Sep 19, 2025
216d993
fix(ui): refactor SearchBar component for SonarQube compliance
nancymuyeh Sep 19, 2025
e04b8bd
fix(ui): refactor SearchBar component for SonarQube compliance
nancymuyeh Sep 19, 2025
3328a72
chore(ui): merge main
nancymuyeh Sep 19, 2025
89b66c6
fix(ui): refactor SearchBar component for SonarQube compliance
nancymuyeh Sep 19, 2025
1161ec1
fix(ui): refactor SearchBar component with more simplifies functionality
nancymuyeh Sep 19, 2025
cd8acd7
fix(ui): update searchbar variants and styling
nancymuyeh Sep 19, 2025
1e6e858
fix(ui): remove unused dependencies
nancymuyeh Sep 19, 2025
103e6c8
fix(ui): remove unused dependencies
nancymuyeh Sep 22, 2025
0cd4017
fix(ui): update searchbar componenet to use global styling from style…
nancymuyeh Sep 22, 2025
8ba9f4b
Merge branch 'main' into 31-design-and-implement-shared-reusable-sear…
nancymuyeh Sep 22, 2025
e885bf6
fix(ui): fix minor lint issue
nancymuyeh Sep 22, 2025
9d2d22f
fix(ui): merge main
nancymuyeh Sep 22, 2025
632273e
Merge remote-tracking branch 'origin' into 31-design-and-implement-sh…
nancymuyeh Sep 22, 2025
eafd972
Merge branch 'main' into 31-design-and-implement-shared-reusable-sear…
nancymuyeh Sep 22, 2025
0fffcc2
Merge branch '31-design-and-implement-shared-reusable-search-bar-comp…
nancymuyeh Sep 23, 2025
9999bf2
fix(ui): fix minor lint issue
nancymuyeh Sep 23, 2025
f87027b
fix(ui): fix minor lint issue
nancymuyeh Sep 23, 2025
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
496 changes: 496 additions & 0 deletions docs/shared-components/searchbar.md

Large diffs are not rendered by default.

11 changes: 10 additions & 1 deletion frontend/account-manager-app/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import "@fineract-apps/ui/styles.css";
import { Button } from "@fineract-apps/ui";
import { Button, SearchBar } from "@fineract-apps/ui";
import { useState } from "react";

function App() {
const [searchValue, setSearchValue] = useState("");

return (
<div style={{ padding: "20px" }}>
<h1>Account Manager App</h1>
<p>
This button is a shared component from the '@fineract-apps/ui' package.
</p>
<SearchBar
value={searchValue}
onValueChange={setSearchValue}
placeholder="Search transactions..."
variant="withButton"
/>
<Button>Click me!</Button>
<Button>Click me!</Button>

Expand Down
2 changes: 2 additions & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"downshift": "^9.0.10",
"formik": "^2.4.6",
"lucide-react": "^0.544.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"tailwind-merge": "^3.3.1",
"use-debounce": "^10.0.6",
"zod": "^4.1.9",
"zod-formik-adapter": "2.0.0"
},
Expand Down
257 changes: 257 additions & 0 deletions packages/ui/src/components/SearchBar/SearchBar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
/**
* @jest-environment jsdom
*/
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SearchBar } from "./SearchBar";

describe("SearchBar", () => {
const mockOnValueChange = jest.fn();
const mockOnSearch = jest.fn();

const defaultProps = {
onValueChange: mockOnValueChange,
onSearch: mockOnSearch,
};

beforeEach(() => {
jest.clearAllMocks();
});

describe("Basic Rendering", () => {
it("renders with default props", () => {
render(<SearchBar />);
expect(screen.getByRole("textbox")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument();
});

it("renders with custom placeholder", () => {
render(<SearchBar placeholder="Find items..." />);
expect(screen.getByPlaceholderText("Find items...")).toBeInTheDocument();
});

it("applies custom className", () => {
const { container } = render(<SearchBar className="custom-class" />);
expect(container.firstChild).toHaveClass("custom-class");
});

it("renders disabled state", () => {
render(<SearchBar disabled />);
expect(screen.getByRole("textbox")).toBeDisabled();
});
});

describe("Value Management", () => {
it("displays controlled value", () => {
render(<SearchBar value="test value" {...defaultProps} />);
expect(screen.getByDisplayValue("test value")).toBeInTheDocument();
});

it("calls onValueChange when typing", async () => {
const user = userEvent.setup();
render(<SearchBar {...defaultProps} />);

const input = screen.getByRole("textbox");
await user.type(input, "hello");

expect(mockOnValueChange).toHaveBeenCalledTimes(5); // Each character
expect(mockOnValueChange).toHaveBeenNthCalledWith(1, "h");
expect(mockOnValueChange).toHaveBeenNthCalledWith(2, "e");
expect(mockOnValueChange).toHaveBeenNthCalledWith(3, "l");
expect(mockOnValueChange).toHaveBeenNthCalledWith(4, "l");
expect(mockOnValueChange).toHaveBeenNthCalledWith(5, "o");
});

it("handles empty initial value", () => {
render(<SearchBar value="" {...defaultProps} />);
expect(screen.getByRole("textbox")).toHaveValue("");
});
});

describe("Search Functionality", () => {
it("triggers onSearch when Enter is pressed", async () => {
const user = userEvent.setup();
render(<SearchBar value="search term" {...defaultProps} />);

const input = screen.getByRole("textbox");
await user.type(input, "{Enter}");

expect(mockOnSearch).toHaveBeenCalledWith("search term");
});

it("does not trigger onSearch on other keys", async () => {
const user = userEvent.setup();
render(<SearchBar value="test" {...defaultProps} />);

const input = screen.getByRole("textbox");
await user.type(input, "{Space}{Tab}");

expect(mockOnSearch).not.toHaveBeenCalled();
});
});

describe("Clear Functionality", () => {
it("shows clear button when showClear is true and has value", () => {
render(<SearchBar value="test" showClear {...defaultProps} />);
expect(screen.getByLabelText("Clear input")).toBeInTheDocument();
});

it("hides clear button when showClear is false", () => {
render(<SearchBar value="test" showClear={false} {...defaultProps} />);
expect(screen.queryByLabelText("Clear input")).not.toBeInTheDocument();
});

it("hides clear button when no value", () => {
render(<SearchBar value="" showClear {...defaultProps} />);
expect(screen.queryByLabelText("Clear input")).not.toBeInTheDocument();
});

it("clears input when clear button is clicked", async () => {
const user = userEvent.setup();
render(<SearchBar value="test" showClear {...defaultProps} />);

const clearButton = screen.getByLabelText("Clear input");
await user.click(clearButton);

expect(mockOnValueChange).toHaveBeenCalledWith("");
});
});

describe("Loading State", () => {
it("shows loading spinner when isLoading is true", () => {
render(<SearchBar isLoading {...defaultProps} />);
// Look for the loading icon by class
const loadingIcon = document.querySelector(".animate-spin");
expect(loadingIcon).toBeInTheDocument();
});

it("hides loading spinner when isLoading is false", () => {
render(<SearchBar isLoading={false} {...defaultProps} />);
const loadingIcon = document.querySelector(".animate-spin");
expect(loadingIcon).not.toBeInTheDocument();
});
});

describe("Variants", () => {
it("renders default variant", () => {
const { container } = render(<SearchBar variant="default" />);
expect(container.firstChild?.firstChild).toHaveClass(
"flex items-center gap-2",
);
});

it("renders withButton variant", () => {
render(<SearchBar variant="withButton" {...defaultProps} />);
expect(
screen.getByRole("button", { name: /search/i }),
).toBeInTheDocument();
});

it("triggers onSearch when search button is clicked", async () => {
const user = userEvent.setup();
render(
<SearchBar
variant="withButton"
value="button search"
{...defaultProps}
/>,
);

const searchButton = screen.getByRole("button", { name: /search/i });
await user.click(searchButton);

expect(mockOnSearch).toHaveBeenCalledWith("button search");
});

it("renders expandable variant collapsed by default", () => {
render(<SearchBar variant="expandable" />);
expect(screen.getByLabelText("Open search")).toBeInTheDocument();
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
});

it("expands when expandable search button is clicked", async () => {
const user = userEvent.setup();
render(<SearchBar variant="expandable" {...defaultProps} />);

const expandButton = screen.getByLabelText("Open search");
await user.click(expandButton);

expect(screen.getByRole("textbox")).toBeInTheDocument();
expect(screen.queryByLabelText("Open search")).not.toBeInTheDocument();
});

it("collapses expandable on Enter key", async () => {
const user = userEvent.setup();
render(<SearchBar variant="expandable" {...defaultProps} />);

// First expand
await user.click(screen.getByLabelText("Open search"));
expect(screen.getByRole("textbox")).toBeInTheDocument();

// Then press Enter
await user.type(screen.getByRole("textbox"), "{Enter}");

// Should be collapsed again
await waitFor(() => {
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
expect(screen.getByLabelText("Open search")).toBeInTheDocument();
});
});

it("collapses expandable on Escape key", async () => {
const user = userEvent.setup();
render(<SearchBar variant="expandable" {...defaultProps} />);

// First expand
await user.click(screen.getByLabelText("Open search"));
expect(screen.getByRole("textbox")).toBeInTheDocument();

// Then press Escape
await user.type(screen.getByRole("textbox"), "{Escape}");

// Should be collapsed
await waitFor(() => {
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
expect(screen.getByLabelText("Open search")).toBeInTheDocument();
});
});
});

describe("Size Variants", () => {
it("applies small size class", () => {
const { container } = render(<SearchBar size="sm" />);
expect(container.querySelector(".h-8")).toBeInTheDocument();
});

it("applies medium size class (default)", () => {
const { container } = render(<SearchBar size="md" />);
expect(container.querySelector(".h-10")).toBeInTheDocument();
});

it("applies large size class", () => {
const { container } = render(<SearchBar size="lg" />);
expect(container.querySelector(".h-12")).toBeInTheDocument();
});
});

describe("Accessibility", () => {
it("has proper ARIA attributes", () => {
render(<SearchBar />);
const input = screen.getByRole("textbox");
expect(input).toBeInTheDocument();
});

it("has accessible button labels", () => {
render(<SearchBar value="test" showClear variant="withButton" />);
expect(screen.getByLabelText("Clear input")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /search/i }),
).toBeInTheDocument();
});

it("has accessible expandable button", () => {
render(<SearchBar variant="expandable" />);
expect(screen.getByLabelText("Open search")).toBeInTheDocument();
});
});
});
Loading
Loading