diff --git a/packages/lib/src/search-bar/SearchBar.accessibility.test.tsx b/packages/lib/src/search-bar/SearchBar.accessibility.test.tsx new file mode 100644 index 000000000..36671272f --- /dev/null +++ b/packages/lib/src/search-bar/SearchBar.accessibility.test.tsx @@ -0,0 +1,58 @@ +import { render } from "@testing-library/react"; +import DxcSearchBar from "./SearchBar"; +import DxcSearchBarTrigger from "./SearchBarTrigger"; +import { axe } from "../../test/accessibility/axe-helper"; + +describe("SearchBar component accessibility tests", () => { + it("Should not have basic accessibility issues", async () => { + const { container } = render(); + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); + + it("Should not have basic accessibility issues with placeholder", async () => { + const { container } = render(); + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); + + it("Should not have basic accessibility issues with disabled state", async () => { + const { container } = render(); + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); + + it("Should not have basic accessibility issues with cancel button", async () => { + const { container } = render( {}} />); + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); + + it("Should not have basic accessibility issues with all props", async () => { + const { container } = render( + {}} + onEnter={() => {}} + onBlur={() => {}} + onCancel={() => {}} + /> + ); + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); +}); + +describe("SearchBarTrigger component accessibility tests", () => { + it("Should not have basic accessibility issues", async () => { + const { container } = render(); + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); + + it("Should not have basic accessibility issues with onTriggerClick", async () => { + const { container } = render( {}} />); + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); +}); diff --git a/packages/lib/src/search-bar/SearchBar.stories.tsx b/packages/lib/src/search-bar/SearchBar.stories.tsx new file mode 100644 index 000000000..048145b97 --- /dev/null +++ b/packages/lib/src/search-bar/SearchBar.stories.tsx @@ -0,0 +1,126 @@ +import { Meta, StoryObj } from "@storybook/react-vite"; +import ExampleContainer from "../../.storybook/components/ExampleContainer"; +import Title from "../../.storybook/components/Title"; +import DxcSearchBarTrigger from "./SearchBarTrigger"; +import { useState } from "react"; +import DxcSearchBar from "./SearchBar"; +import DxcFlex from "../flex/Flex"; +import DxcContainer from "../container/Container"; + +export default { + title: "Searchbar", + component: DxcSearchBar, +} satisfies Meta; + +const SearchBarComponent = () => { + const [showSearch, setShowSearch] = useState(false); + + return ( + + {!showSearch ? ( + setShowSearch(!showSearch)} /> + ) : ( + { + console.log("onBlur", value); + }} + onChange={(value) => console.log("onChange", value)} + onEnter={(value) => { + console.log("onEnter", value); + setShowSearch(false); + }} + onCancel={() => setShowSearch(false)} + /> + )} + + ); +}; + +const SearchBar = () => { + return ( + <> + + <ExampleContainer> + <SearchBarComponent /> + </ExampleContainer> + + <Title title="States" theme="light" level={2} /> + <ExampleContainer> + <Title title="Default" theme="light" level={4} /> + <DxcSearchBar + placeholder="Search..." + onBlur={(value) => { + console.log("onBlur", value); + }} + onChange={(value) => console.log("onChange", value)} + onEnter={(value) => { + console.log("onEnter", value); + }} + /> + </ExampleContainer> + <ExampleContainer pseudoState="pseudo-hover"> + <Title title="Hover" theme="light" level={4} /> + <DxcSearchBar + placeholder="Search..." + onBlur={(value) => { + console.log("onBlur", value); + }} + onChange={(value) => console.log("onChange", value)} + onEnter={(value) => { + console.log("onEnter", value); + }} + /> + </ExampleContainer> + <ExampleContainer pseudoState="pseudo-focus-within"> + <Title title="Focus" theme="light" level={4} /> + <DxcSearchBar + placeholder="Search..." + onBlur={(value) => { + console.log("onBlur", value); + }} + onChange={(value) => console.log("onChange", value)} + onEnter={(value) => { + console.log("onEnter", value); + }} + /> + </ExampleContainer> + <ExampleContainer> + <Title title="Disabled" theme="light" level={4} /> + <DxcSearchBar + placeholder="Search..." + onBlur={(value) => { + console.log("onBlur", value); + }} + onChange={(value) => console.log("onChange", value)} + onEnter={(value) => { + console.log("onEnter", value); + }} + disabled + /> + </ExampleContainer> + + <Title title="Small Searchbar" theme="light" level={2} /> + <ExampleContainer> + <DxcContainer width="220px"> + <DxcSearchBar + placeholder="Search..." + onBlur={(value) => { + console.log("onBlur", value); + }} + onChange={(value) => console.log("onChange", value)} + onEnter={(value) => { + console.log("onEnter", value); + }} + /> + </DxcContainer> + </ExampleContainer> + </> + ); +}; + +type Story = StoryObj<typeof DxcSearchBar>; + +export const Chromatic: Story = { + render: SearchBar, +}; diff --git a/packages/lib/src/search-bar/SearchBar.test.tsx b/packages/lib/src/search-bar/SearchBar.test.tsx new file mode 100644 index 000000000..2a59203c0 --- /dev/null +++ b/packages/lib/src/search-bar/SearchBar.test.tsx @@ -0,0 +1,99 @@ +import { render, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import DxcSearchBar from "./SearchBar"; +import DxcSearchBarTrigger from "./SearchBarTrigger"; + +describe("SearchBarTrigger component tests", () => { + test("Renders correctly", () => { + const { getByRole } = render(<DxcSearchBarTrigger />); + + const button = getByRole("button"); + expect(button).toBeTruthy(); + }); + + test("Calls onTriggerClick when button is clicked", () => { + const onTriggerClick = jest.fn(); + const { getByRole } = render(<DxcSearchBarTrigger onTriggerClick={onTriggerClick} />); + + const button = getByRole("button"); + userEvent.click(button); + + expect(onTriggerClick).toHaveBeenCalledTimes(1); + }); +}); + +describe("SearchBar component tests", () => { + test("Renders correctly", () => { + const { getByPlaceholderText } = render(<DxcSearchBar placeholder="Search..." />); + + const text = getByPlaceholderText("Search..."); + expect(text).toBeTruthy(); + }); + + test("Calls onChange when typing", () => { + const onChange = jest.fn(); + const { getByRole } = render(<DxcSearchBar onChange={onChange} />); + + const input = getByRole("textbox") as HTMLInputElement; + userEvent.type(input, "hello"); + + expect(onChange).toHaveBeenCalled(); + expect(onChange).toHaveBeenLastCalledWith("hello"); + }); + + test("Calls onEnter with value when pressing Enter", () => { + const onEnter = jest.fn(); + const { getByRole } = render(<DxcSearchBar onEnter={onEnter} />); + + const input = getByRole("textbox") as HTMLInputElement; + userEvent.type(input, "search text"); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onEnter).toHaveBeenCalledTimes(1); + expect(onEnter).toHaveBeenCalledWith("search text"); + }); + + test("Clears value when clicking clear icon", () => { + const { getByRole } = render(<DxcSearchBar />); + + const input = getByRole("textbox") as HTMLInputElement; + userEvent.type(input, "abc"); + + const clearButton = getByRole("button"); + expect(clearButton).toBeTruthy(); + + userEvent.click(clearButton); + expect(input.value).toBe(""); + }); + + test("Clears value when pressing Escape", () => { + const { getByRole } = render(<DxcSearchBar />); + + const input = getByRole("textbox") as HTMLInputElement; + userEvent.type(input, "xyz"); + fireEvent.keyDown(input, { key: "Escape" }); + + expect(input.value).toBe(""); + }); + + test("Calls onBlur with current value when blurred", () => { + const onBlur = jest.fn(); + const { getByRole } = render(<DxcSearchBar onBlur={onBlur} />); + + const input = getByRole("textbox") as HTMLInputElement; + userEvent.type(input, "blur me"); + fireEvent.blur(input); + + expect(onBlur).toHaveBeenCalledWith("blur me"); + }); + + test("Calls onCancel when Cancel button is clicked", () => { + const onCancel = jest.fn(); + const { getByRole } = render(<DxcSearchBar onCancel={onCancel} />); + + const cancelButton = getByRole("button", { name: /Cancel/i }); + userEvent.click(cancelButton); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/lib/src/search-bar/SearchBar.tsx b/packages/lib/src/search-bar/SearchBar.tsx new file mode 100644 index 000000000..fe3b8e058 --- /dev/null +++ b/packages/lib/src/search-bar/SearchBar.tsx @@ -0,0 +1,138 @@ +import styled from "@emotion/styled"; +import DxcButton from "../button/Button"; +import DxcFlex from "../flex/Flex"; +import { SearchBarProps } from "./types"; +import DxcActionIcon from "../action-icon/ActionIcon"; +import { KeyboardEvent, useContext, useRef, useState } from "react"; +import { HalstackLanguageContext } from "../HalstackContext"; +import { css } from "@emotion/react"; +import DxcIcon from "../icon/Icon"; + +const SearchBarContainer = styled.div<{ disabled: Required<SearchBarProps>["disabled"] }>` + width: 100%; + min-width: 200px; + max-width: 720px; + height: var(--height-m); + display: flex; + align-items: center; + gap: var(--spacing-gap-s); + border-radius: var(--border-radius-xl); + border: var(--border-width-s) var(--border-style-default) var(--border-color-neutral-dark); + box-sizing: border-box; + padding: 0 var(--spacing-padding-s); + color: var(--color-fg-neutral-dark); + + ${({ disabled }) => + !disabled + ? css` + &:hover { + border-color: var(--border-color-primary-strong); + } + &:focus, + &:focus-within, + &:focus-visible { + border-color: transparent; + outline-offset: -2px; + outline: var(--border-width-m) var(--border-style-default) var(--border-color-secondary-medium); + } + ` + : css` + color: var(--color-fg-neutral-medium); + border-color: var(--border-color-neutral-strong); + cursor: not-allowed; + `} +`; + +const SearchBarInput = styled.input<{ disabled: Required<SearchBarProps>["disabled"] }>` + width: 100%; + max-width: 100%; + background: none; + border: none; + outline: none; + padding: 0; + font-family: var(--typography-font-family); + font-size: var(--typography-label-m); + font-weight: var(--typography-label-regular); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: ${({ disabled }) => (disabled ? "not-allowed" : "text")}; +`; + +const DxcSearchBar = ({ + autoFocus, + disabled = false, + onBlur, + onCancel, + onChange, + onEnter, + placeholder, +}: SearchBarProps) => { + const translatedLabels = useContext(HalstackLanguageContext); + const inputRef = useRef<HTMLInputElement>(null); + const [innerValue, setInnerValue] = useState(""); + + const handleClearActionOnClick = () => { + setInnerValue(""); + inputRef.current?.focus(); + }; + + const handleSearchChangeValue = (value: string) => { + setInnerValue(value); + if (typeof onChange === "function") { + onChange(value); + } + }; + + const handleInputOnKeyDown = (e: KeyboardEvent<HTMLInputElement>) => { + switch (e.key) { + case "Esc": + case "Escape": + e.preventDefault(); + if (innerValue.length > 0) { + handleClearActionOnClick(); + } + break; + case "Enter": + if (typeof onEnter === "function") { + onEnter(innerValue); + } + break; + default: + break; + } + }; + + return ( + <DxcFlex gap="var(--spacing-gap-m)" alignItems="center" justifyContent="center" grow={1}> + <SearchBarContainer disabled={disabled} autoFocus={autoFocus}> + <DxcIcon icon="search" /> + <SearchBarInput + ref={inputRef} + value={innerValue} + placeholder={placeholder} + onBlur={(e) => typeof onBlur === "function" && onBlur(e.target.value)} + onChange={(e) => handleSearchChangeValue(e.target.value)} + onKeyDown={handleInputOnKeyDown} + disabled={disabled} + /> + {!disabled && innerValue.length > 0 && ( + <DxcActionIcon + size="xsmall" + shape="circle" + icon="cancel" + onClick={handleClearActionOnClick} + tabIndex={0} + title={!disabled ? translatedLabels.textInput.clearFieldActionTitle : undefined} + /> + )} + </SearchBarContainer> + + {typeof onCancel === "function" && ( + <DxcButton label="Cancel" title="Cancel" onClick={onCancel} mode="tertiary" size={{ height: "medium" }} /> + )} + </DxcFlex> + ); +}; + +export default DxcSearchBar; diff --git a/packages/lib/src/search-bar/SearchBarTrigger.tsx b/packages/lib/src/search-bar/SearchBarTrigger.tsx new file mode 100644 index 000000000..26b647747 --- /dev/null +++ b/packages/lib/src/search-bar/SearchBarTrigger.tsx @@ -0,0 +1,15 @@ +import DxcButton from "../button/Button"; +import { SearchBarTriggerProps } from "./types"; + +const DxcSearchBarTrigger = ({ onTriggerClick }: SearchBarTriggerProps) => ( + <DxcButton + onClick={onTriggerClick} + icon="Search" + mode="tertiary" + title="Search" + semantic="default" + size={{ height: "medium" }} + /> +); + +export default DxcSearchBarTrigger; diff --git a/packages/lib/src/search-bar/types.ts b/packages/lib/src/search-bar/types.ts new file mode 100644 index 000000000..5d67a8f39 --- /dev/null +++ b/packages/lib/src/search-bar/types.ts @@ -0,0 +1,36 @@ +export type SearchBarTriggerProps = { + /** + * Function invoked when the trigger button is clicked. + */ + onTriggerClick?: () => void; +}; +export type SearchBarProps = { + /** + * If true, the search bar input will be focused when rendered. + */ + autoFocus?: boolean; + /** + * If true, the component will be disabled. + */ + disabled?: boolean; + /** + * Function invoked when the search bar loses focus. + */ + onBlur?: (value: string) => void; + /** + * Function invoked when the user cancels the search. + */ + onCancel?: () => void; + /** + * Function invoked when the user changes the input value. + */ + onChange?: (value: string) => void; + /** + * Function invoked when the Enter key is pressed. + */ + onEnter?: (value: string) => void; + /** + * Placeholder text displayed in the search bar input field. + */ + placeholder?: string; +};