diff --git a/src/Common/SearchableDropdown.styles.ts b/src/Common/SearchableDropdown.styles.ts new file mode 100644 index 000000000..3e21e4717 --- /dev/null +++ b/src/Common/SearchableDropdown.styles.ts @@ -0,0 +1,65 @@ +import { IButtonStyles, IStackStyles, ITextStyles } from "@fluentui/react"; +import * as React from "react"; + +export const getDropdownButtonStyles = (disabled: boolean): IButtonStyles => ({ + root: { + width: "100%", + height: "32px", + padding: "0 28px 0 8px", + border: "1px solid #8a8886", + background: "#fff", + color: "#323130", + textAlign: "left", + cursor: disabled ? "not-allowed" : "pointer", + position: "relative", + }, + label: { + fontWeight: "normal", + fontSize: "14px", + }, +}); + +export const buttonLabelStyles: ITextStyles = { + root: { + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + display: "block", + }, +}; + +export const chevronStyles: React.CSSProperties = { + position: "absolute", + right: "8px", + top: "50%", + transform: "translateY(-50%)", + pointerEvents: "none", +}; + +export const calloutContentStyles: IStackStyles = { + root: { + display: "flex", + flexDirection: "column", + }, +}; + +export const listContainerStyles: IStackStyles = { + root: { + maxHeight: "300px", + overflowY: "auto", + }, +}; + +export const getItemStyles = (isSelected: boolean): React.CSSProperties => ({ + padding: "8px 12px", + cursor: "pointer", + fontSize: "14px", + backgroundColor: isSelected ? "#e6e6e6" : "transparent", +}); + +export const emptyMessageStyles: ITextStyles = { + root: { + padding: "8px 12px", + color: "#605e5c", + }, +}; diff --git a/src/Common/SearchableDropdown.test.tsx b/src/Common/SearchableDropdown.test.tsx new file mode 100644 index 000000000..815d1c09e --- /dev/null +++ b/src/Common/SearchableDropdown.test.tsx @@ -0,0 +1,200 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import React from "react"; +import { SearchableDropdown } from "./SearchableDropdown"; + +interface TestItem { + id: string; + name: string; +} + +describe("SearchableDropdown", () => { + const mockItems: TestItem[] = [ + { id: "1", name: "Item One" }, + { id: "2", name: "Item Two" }, + { id: "3", name: "Item Three" }, + ]; + + const defaultProps = { + label: "Test Label", + items: mockItems, + selectedItem: null, + onSelect: jest.fn(), + getKey: (item: TestItem) => item.id, + getDisplayText: (item: TestItem) => item.name, + placeholder: "Select an item", + filterPlaceholder: "Filter items", + className: "test-dropdown", + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should render with label and placeholder", () => { + render(); + expect(screen.getByText("Test Label")).toBeInTheDocument(); + expect(screen.getByText("Select an item")).toBeInTheDocument(); + }); + + it("should display selected item", () => { + const propsWithSelection = { + ...defaultProps, + selectedItem: mockItems[0], + }; + render(); + expect(screen.getByText("Item One")).toBeInTheDocument(); + }); + + it("should show 'No items found' when items array is empty", () => { + const propsWithEmptyItems = { + ...defaultProps, + items: [], + }; + render(); + expect(screen.getByText("No Test Labels Found")).toBeInTheDocument(); + }); + + it("should open dropdown when button is clicked", () => { + render(); + const button = screen.getByText("Select an item"); + fireEvent.click(button); + expect(screen.getByPlaceholderText("Filter items")).toBeInTheDocument(); + }); + + it("should filter items based on search text", () => { + render(); + const button = screen.getByText("Select an item"); + fireEvent.click(button); + + const searchBox = screen.getByPlaceholderText("Filter items"); + fireEvent.change(searchBox, { target: { value: "Two" } }); + + expect(screen.getByText("Item Two")).toBeInTheDocument(); + expect(screen.queryByText("Item One")).not.toBeInTheDocument(); + expect(screen.queryByText("Item Three")).not.toBeInTheDocument(); + }); + + it("should call onSelect when an item is clicked", () => { + const onSelectMock = jest.fn(); + const propsWithMock = { + ...defaultProps, + onSelect: onSelectMock, + }; + render(); + + const button = screen.getByText("Select an item"); + fireEvent.click(button); + + const item = screen.getByText("Item Two"); + fireEvent.click(item); + + expect(onSelectMock).toHaveBeenCalledWith(mockItems[1]); + }); + + it("should close dropdown after selecting an item", () => { + render(); + + const button = screen.getByText("Select an item"); + fireEvent.click(button); + + expect(screen.getByPlaceholderText("Filter items")).toBeInTheDocument(); + + const item = screen.getByText("Item One"); + fireEvent.click(item); + + expect(screen.queryByPlaceholderText("Filter items")).not.toBeInTheDocument(); + }); + + it("should disable button when disabled prop is true", () => { + const propsWithDisabled = { + ...defaultProps, + disabled: true, + }; + render(); + + const button = screen.getByRole("button"); + expect(button).toBeDisabled(); + }); + + it("should not open dropdown when disabled", () => { + const propsWithDisabled = { + ...defaultProps, + disabled: true, + }; + render(); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + expect(screen.queryByPlaceholderText("Filter items")).not.toBeInTheDocument(); + }); + + it("should show 'No items found' when search yields no results", () => { + render(); + + const button = screen.getByText("Select an item"); + fireEvent.click(button); + + const searchBox = screen.getByPlaceholderText("Filter items"); + fireEvent.change(searchBox, { target: { value: "Nonexistent" } }); + + expect(screen.getByText("No items found")).toBeInTheDocument(); + }); + + it("should handle case-insensitive filtering", () => { + render(); + + const button = screen.getByText("Select an item"); + fireEvent.click(button); + + const searchBox = screen.getByPlaceholderText("Filter items"); + fireEvent.change(searchBox, { target: { value: "two" } }); + + expect(screen.getByText("Item Two")).toBeInTheDocument(); + expect(screen.queryByText("Item One")).not.toBeInTheDocument(); + }); + + it("should clear filter text when dropdown is closed and reopened", () => { + render(); + + const button = screen.getByText("Select an item"); + fireEvent.click(button); + + const searchBox = screen.getByPlaceholderText("Filter items"); + fireEvent.change(searchBox, { target: { value: "Two" } }); + + // Close dropdown by selecting an item + const item = screen.getByText("Item Two"); + fireEvent.click(item); + + // Reopen dropdown + fireEvent.click(button); + + // Filter text should be cleared + const reopenedSearchBox = screen.getByPlaceholderText("Filter items"); + expect(reopenedSearchBox).toHaveValue(""); + }); + + it("should use custom placeholder text", () => { + const propsWithCustomPlaceholder = { + ...defaultProps, + placeholder: "Choose an option", + }; + render(); + expect(screen.getByText("Choose an option")).toBeInTheDocument(); + }); + + it("should use custom filter placeholder text", () => { + const propsWithCustomFilterPlaceholder = { + ...defaultProps, + filterPlaceholder: "Search here", + }; + render(); + + const button = screen.getByText("Select an item"); + fireEvent.click(button); + + expect(screen.getByPlaceholderText("Search here")).toBeInTheDocument(); + }); +}); diff --git a/src/Common/SearchableDropdown.tsx b/src/Common/SearchableDropdown.tsx index f740f7973..eca370eb5 100644 --- a/src/Common/SearchableDropdown.tsx +++ b/src/Common/SearchableDropdown.tsx @@ -1,6 +1,24 @@ -import { Callout, DirectionalHint, ISearchBoxStyles, Label, SearchBox } from "@fluentui/react"; +import { + Callout, + DefaultButton, + DirectionalHint, + ISearchBoxStyles, + Label, + SearchBox, + Stack, + Text, +} from "@fluentui/react"; import * as React from "react"; import { useCallback, useRef, useState } from "react"; +import { + buttonLabelStyles, + calloutContentStyles, + chevronStyles, + emptyMessageStyles, + getDropdownButtonStyles, + getItemStyles, + listContainerStyles, +} from "./SearchableDropdown.styles"; interface SearchableDropdownProps { label: string; @@ -14,10 +32,7 @@ interface SearchableDropdownProps { className?: string; disabled?: boolean; onDismiss?: () => void; - buttonStyles?: React.CSSProperties; searchBoxStyles?: Partial; - listStyles?: React.CSSProperties; - itemStyles?: React.CSSProperties; } export const SearchableDropdown = ({ @@ -32,44 +47,11 @@ export const SearchableDropdown = ({ className, disabled = false, onDismiss, - buttonStyles: customButtonStyles, searchBoxStyles: customSearchBoxStyles, - listStyles: customListStyles, - itemStyles: customItemStyles, }: SearchableDropdownProps): React.ReactElement => { const [isOpen, setIsOpen] = useState(false); const [filterText, setFilterText] = useState(""); - const buttonRef = useRef(null); - - const defaultButtonStyles: React.CSSProperties = { - width: "100%", - height: "32px", - padding: "0 28px 0 8px", - border: "1px solid #8a8886", - background: "#fff", - color: "#323130", - textAlign: "left", - cursor: disabled ? "not-allowed" : "pointer", - position: "relative", - fontFamily: "inherit", - fontSize: "14px", - }; - - const defaultListStyles: React.CSSProperties = { - maxHeight: "300px", - overflowY: "auto", - }; - - const defaultItemStyles: React.CSSProperties = { - padding: "8px 12px", - cursor: "pointer", - fontSize: "14px", - }; - - // Merge custom styles with defaults - const buttonStyles = { ...defaultButtonStyles, ...customButtonStyles }; - const listStyles = { ...defaultListStyles, ...customListStyles }; - const itemStyles = { ...defaultItemStyles, ...customItemStyles }; + const buttonRef = useRef(null); const closeDropdown = useCallback(() => { setIsOpen(false); @@ -83,24 +65,13 @@ export const SearchableDropdown = ({ onDismiss?.(); }, [closeDropdown, onDismiss]); - const handleButtonClick = useCallback( - (event: React.MouseEvent) => { - if (disabled) { - return; - } - event.preventDefault(); - event.stopPropagation(); - - if (isOpen) { - closeDropdown(); - return; - } - - setIsOpen(true); + const handleButtonClick = useCallback(() => { + if (disabled) { return; - }, - [isOpen, closeDropdown, disabled], - ); + } + + setIsOpen(!isOpen); + }, [isOpen, disabled]); const handleSelect = useCallback( (item: T) => { @@ -117,33 +88,23 @@ export const SearchableDropdown = ({ : placeholder; const buttonId = `${className}-button`; + const buttonStyles = getDropdownButtonStyles(disabled); return ( -
+ - + {buttonLabel} + + +
{isOpen && ( ({ gapSpace={0} setInitialFocus > -
+ setFilterText(newValue || "")} styles={customSearchBoxStyles} /> -
+ {filteredItems && filteredItems.length > 0 ? ( filteredItems.map((item) => { const key = getKey(item); @@ -169,26 +130,23 @@ export const SearchableDropdown = ({
handleSelect(item)} - style={{ - ...itemStyles, - backgroundColor: isSelected ? "#e6e6e6" : "transparent", - }} + style={getItemStyles(isSelected)} onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "#f3f2f1")} onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = isSelected ? "#e6e6e6" : "transparent") } > - {getDisplayText(item)} + {getDisplayText(item)}
); }) ) : ( -
No items found
+ No items found )} -
-
+ +
)} - + ); };