From a186ea7982edff9c54f7e32f6c817c22540c61a9 Mon Sep 17 00:00:00 2001 From: Pelayo Felgueroso Date: Thu, 8 Jan 2026 12:54:38 +0100 Subject: [PATCH] Implement search bar in Sidenav component and update related types and tests --- apps/website/pages/_app.tsx | 16 +-- .../sidenav/code/SidenavCodePage.tsx | 22 +++ packages/lib/src/search-bar/SearchBar.tsx | 136 +++++++++--------- packages/lib/src/search-bar/types.ts | 1 + .../sidenav/Sidenav.accessibility.test.tsx | 4 +- packages/lib/src/sidenav/Sidenav.stories.tsx | 7 + packages/lib/src/sidenav/Sidenav.test.tsx | 19 ++- packages/lib/src/sidenav/Sidenav.tsx | 21 ++- packages/lib/src/sidenav/types.ts | 5 + 9 files changed, 142 insertions(+), 89 deletions(-) diff --git a/apps/website/pages/_app.tsx b/apps/website/pages/_app.tsx index 2603ad349..ed382f285 100644 --- a/apps/website/pages/_app.tsx +++ b/apps/website/pages/_app.tsx @@ -2,7 +2,7 @@ import { ReactElement, ReactNode, useMemo, useState } from "react"; import type { NextPage } from "next"; import type { AppProps } from "next/app"; import Head from "next/head"; -import { DxcApplicationLayout, DxcTextInput, DxcToastsQueue } from "@dxc-technology/halstack-react"; +import { DxcApplicationLayout, DxcToastsQueue } from "@dxc-technology/halstack-react"; import MainContent from "@/common/MainContent"; import { useRouter } from "next/router"; import { LinkDetails, LinksSectionDetails, LinksSections } from "@/common/pagesList"; @@ -108,19 +108,7 @@ export default function App({ Component, pageProps, emotionCache = clientSideEmo } - topContent={ - isExpanded && ( - { - setFilter(value); - }} - size="fillParent" - clearable - /> - ) - } + searchBar={{ placeholder: "Search docs", onChange: (value) => setFilter(value) }} expanded={isExpanded} onExpandedChange={() => { setIsExpanded((currentlyExpanded) => !currentlyExpanded); diff --git a/apps/website/screens/components/sidenav/code/SidenavCodePage.tsx b/apps/website/screens/components/sidenav/code/SidenavCodePage.tsx index d0bd0bc47..b21fb5400 100644 --- a/apps/website/screens/components/sidenav/code/SidenavCodePage.tsx +++ b/apps/website/screens/components/sidenav/code/SidenavCodePage.tsx @@ -24,6 +24,15 @@ const sectionTypeString = `{ title?: string }; }`; +const searchBarTypeString = `{ + autoFocus?: boolean; + disabled?: boolean; + onBlur: (value: string) => void; + onChange: (value: string) => void; + onEnter: (value: string) => void; + placeholder?: string; +}`; + const sections = [ { title: "Props", @@ -147,6 +156,19 @@ const sections = [ Function called when the expansion state of the sidenav changes. - + + + + + searchBar + + + + {searchBarTypeString} + + When provided, a search bar will be rendered at the top of the sidenav. + - + diff --git a/packages/lib/src/search-bar/SearchBar.tsx b/packages/lib/src/search-bar/SearchBar.tsx index fe3b8e058..113c18d1d 100644 --- a/packages/lib/src/search-bar/SearchBar.tsx +++ b/packages/lib/src/search-bar/SearchBar.tsx @@ -1,9 +1,9 @@ import styled from "@emotion/styled"; import DxcButton from "../button/Button"; import DxcFlex from "../flex/Flex"; -import { SearchBarProps } from "./types"; +import { RefType, SearchBarProps } from "./types"; import DxcActionIcon from "../action-icon/ActionIcon"; -import { KeyboardEvent, useContext, useRef, useState } from "react"; +import { forwardRef, KeyboardEvent, useContext, useRef, useState } from "react"; import { HalstackLanguageContext } from "../HalstackContext"; import { css } from "@emotion/react"; import DxcIcon from "../icon/Icon"; @@ -59,80 +59,74 @@ const SearchBarInput = styled.input<{ disabled: Required["disabl cursor: ${({ disabled }) => (disabled ? "not-allowed" : "text")}; `; -const DxcSearchBar = ({ - autoFocus, - disabled = false, - onBlur, - onCancel, - onChange, - onEnter, - placeholder, -}: SearchBarProps) => { - const translatedLabels = useContext(HalstackLanguageContext); - const inputRef = useRef(null); - const [innerValue, setInnerValue] = useState(""); +const DxcSearchBar = forwardRef( + ({ autoFocus, disabled = false, onBlur, onCancel, onChange, onEnter, placeholder }, ref) => { + const translatedLabels = useContext(HalstackLanguageContext); + const inputRef = useRef(null); + const [innerValue, setInnerValue] = useState(""); - const handleClearActionOnClick = () => { - setInnerValue(""); - inputRef.current?.focus(); - }; + const handleClearActionOnClick = () => { + setInnerValue(""); + inputRef.current?.focus(); + }; - const handleSearchChangeValue = (value: string) => { - setInnerValue(value); - if (typeof onChange === "function") { - onChange(value); - } - }; + const handleSearchChangeValue = (value: string) => { + setInnerValue(value); + if (typeof onChange === "function") { + onChange(value); + } + }; - const handleInputOnKeyDown = (e: KeyboardEvent) => { - 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; - } - }; + const handleInputOnKeyDown = (e: KeyboardEvent) => { + 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 ( - - - - typeof onBlur === "function" && onBlur(e.target.value)} - onChange={(e) => handleSearchChangeValue(e.target.value)} - onKeyDown={handleInputOnKeyDown} - disabled={disabled} - /> - {!disabled && innerValue.length > 0 && ( - + + + typeof onBlur === "function" && onBlur(e.target.value)} + onChange={(e) => handleSearchChangeValue(e.target.value)} + onKeyDown={handleInputOnKeyDown} + disabled={disabled} /> - )} - + {!disabled && innerValue.length > 0 && ( + + )} + - {typeof onCancel === "function" && ( - - )} - - ); -}; + {typeof onCancel === "function" && ( + + )} + + ); + } +); export default DxcSearchBar; diff --git a/packages/lib/src/search-bar/types.ts b/packages/lib/src/search-bar/types.ts index 5d67a8f39..869f84be2 100644 --- a/packages/lib/src/search-bar/types.ts +++ b/packages/lib/src/search-bar/types.ts @@ -4,6 +4,7 @@ export type SearchBarTriggerProps = { */ onTriggerClick?: () => void; }; +export type RefType = HTMLDivElement; export type SearchBarProps = { /** * If true, the search bar input will be focused when rendered. diff --git a/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx b/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx index 157d20d52..7b2ac21e1 100644 --- a/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx +++ b/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx @@ -47,7 +47,9 @@ describe("Sidenav component accessibility tests", () => { ], }, ]; - const { container } = render(); + const { container } = render( + + ); const results = await axe(container); expect(results.violations).toHaveLength(0); }); diff --git a/packages/lib/src/sidenav/Sidenav.stories.tsx b/packages/lib/src/sidenav/Sidenav.stories.tsx index 8c47a0845..e0a68423d 100644 --- a/packages/lib/src/sidenav/Sidenav.stories.tsx +++ b/packages/lib/src/sidenav/Sidenav.stories.tsx @@ -140,6 +140,7 @@ const Sidenav = () => ( @@ -169,6 +170,7 @@ const Sidenav = () => ( @@ -208,6 +210,7 @@ const Collapsed = () => { @@ -261,6 +264,7 @@ const Collapsed = () => { @@ -314,6 +318,7 @@ const Collapsed = () => { @@ -373,6 +378,7 @@ const Hovered = () => ( @@ -405,6 +411,7 @@ const SelectedGroup = () => ( diff --git a/packages/lib/src/sidenav/Sidenav.test.tsx b/packages/lib/src/sidenav/Sidenav.test.tsx index eb0edfc1c..cbbc8c717 100644 --- a/packages/lib/src/sidenav/Sidenav.test.tsx +++ b/packages/lib/src/sidenav/Sidenav.test.tsx @@ -1,5 +1,5 @@ import "@testing-library/jest-dom"; -import { render, fireEvent } from "@testing-library/react"; +import { render, fireEvent, waitFor } from "@testing-library/react"; import DxcSidenav from "./Sidenav"; import { ReactNode } from "react"; @@ -103,4 +103,21 @@ describe("DxcSidenav component", () => { const collapseButton = getByRole("button", { name: "Collapse" }); expect(collapseButton).toBeTruthy(); }); + + test("Sidenav renders search bar when searchBar prop is provided", () => { + const { getByPlaceholderText } = render(); + expect(getByPlaceholderText("Search...")).toBeTruthy(); + }); + + test("Sidenav expands and focuses search input when handleExpandSearch is called", async () => { + const { getByRole, getByPlaceholderText } = render( + + ); + const expandButton = getByRole("button", { name: "Search" }); + fireEvent.click(expandButton); + const searchInput = getByPlaceholderText("Search...") as HTMLInputElement; + await waitFor(() => { + expect(document.activeElement).toBe(searchInput); + }); + }); }); diff --git a/packages/lib/src/sidenav/Sidenav.tsx b/packages/lib/src/sidenav/Sidenav.tsx index 8d2292058..8f7c885c2 100644 --- a/packages/lib/src/sidenav/Sidenav.tsx +++ b/packages/lib/src/sidenav/Sidenav.tsx @@ -5,10 +5,12 @@ import SidenavPropsType from "./types"; import DxcDivider from "../divider/Divider"; import DxcButton from "../button/Button"; import DxcImage from "../image/Image"; -import { useContext, useState } from "react"; +import { useContext, useRef, useState } from "react"; import DxcNavigationTree from "../navigation-tree/NavigationTree"; import DxcInset from "../inset/Inset"; import ApplicationLayoutContext from "../layout/ApplicationLayoutContext"; +import DxcSearchBar from "../search-bar/SearchBar"; +import DxcSearchBarTrigger from "../search-bar/SearchBarTrigger"; const SidenavContainer = styled.div<{ expanded: boolean }>` box-sizing: border-box; @@ -67,11 +69,13 @@ const DxcSidenav = ({ expanded, defaultExpanded = true, onExpandedChange, + searchBar, }: SidenavPropsType): JSX.Element => { const [internalExpanded, setInternalExpanded] = useState(defaultExpanded); const { logo, headerExists } = useContext(ApplicationLayoutContext); const isControlled = expanded !== undefined; const isExpanded = isControlled ? !!expanded : internalExpanded; + const searchBarRef = useRef(null); const handleToggle = () => { const nextState = !isExpanded; @@ -79,6 +83,13 @@ const DxcSidenav = ({ onExpandedChange?.(nextState); }; + const handleExpandSearch = () => { + handleToggle(); + setTimeout(() => { + searchBarRef.current?.querySelector("input")?.focus(); + }, 1); + }; + return ( {appTitle} - {topContent && ( + {(topContent || searchBar) && ( + {searchBar && + (isExpanded ? ( + + ) : ( + + ))} {topContent} )} diff --git a/packages/lib/src/sidenav/types.ts b/packages/lib/src/sidenav/types.ts index 378fd196f..5a315b75a 100644 --- a/packages/lib/src/sidenav/types.ts +++ b/packages/lib/src/sidenav/types.ts @@ -1,5 +1,6 @@ import { ReactElement, ReactNode } from "react"; import { SVG } from "../common/utils"; +import { SearchBarProps } from "../search-bar/types"; type Section = { items: (Item | GroupItem)[]; title?: string }; @@ -34,6 +35,10 @@ type Props = { * Function called when the expansion state of the sidenav changes. */ onExpandedChange?: (value: boolean) => void; + /** + * When provided, a search bar will be rendered at the top of the sidenav. + */ + searchBar?: Omit; /** * The additional content rendered in the upper part of the sidenav, under the branding. */