diff --git a/apps/website/pages/components/popover/code.tsx b/apps/website/pages/components/popover/code.tsx new file mode 100644 index 000000000..97892649d --- /dev/null +++ b/apps/website/pages/components/popover/code.tsx @@ -0,0 +1,17 @@ +import Head from "next/head"; +import type { ReactElement } from "react"; +import PopoverPageLayout from "screens/components/popover/PopoverPageLayout"; +import PopoverCodePage from "screens/components/popover/code/PopoverCodePage"; + +const Code = () => ( + <> + + Popover code — Halstack Design System + + + +); + +Code.getLayout = (page: ReactElement) => {page}; + +export default Code; diff --git a/apps/website/pages/components/popover/index.tsx b/apps/website/pages/components/popover/index.tsx new file mode 100644 index 000000000..2e41d3584 --- /dev/null +++ b/apps/website/pages/components/popover/index.tsx @@ -0,0 +1,17 @@ +import Head from "next/head"; +import type { ReactElement } from "react"; +import PopoverOverviewPage from "screens/components/popover/overview/PopoverOverviewPage"; +import PopoverPageLayout from "screens/components/popover/PopoverPageLayout"; + +const Index = () => ( + <> + + Popover — Halstack Design System + + + +); + +Index.getLayout = (page: ReactElement) => {page}; + +export default Index; diff --git a/apps/website/screens/common/componentsList.json b/apps/website/screens/common/componentsList.json index 7a736d581..23030fb3b 100644 --- a/apps/website/screens/common/componentsList.json +++ b/apps/website/screens/common/componentsList.json @@ -103,7 +103,7 @@ "label": "Toast", "path": "/components/toast", "status": "stable", - "icon": "chat_bubble" + "icon": "toast" }, { "label": "Tooltip", @@ -260,6 +260,12 @@ "path": "/components/inset", "status": "stable", "icon": "padding" + }, + { + "label": "Popover", + "path": "/components/popover", + "status": "experimental", + "icon": "chat_bubble" } ] }, diff --git a/apps/website/screens/components/popover/PopoverPageLayout.tsx b/apps/website/screens/components/popover/PopoverPageLayout.tsx new file mode 100644 index 000000000..1a237abf0 --- /dev/null +++ b/apps/website/screens/components/popover/PopoverPageLayout.tsx @@ -0,0 +1,30 @@ +import { DxcParagraph, DxcFlex } from "@dxc-technology/halstack-react"; +import PageHeading from "@/common/PageHeading"; +import TabsPageHeading from "@/common/TabsPageLayout"; +import ComponentHeading from "@/common/ComponentHeading"; +import { ReactNode } from "react"; + +const PopoverPageHeading = ({ children }: { children: ReactNode }) => { + const tabs = [ + { label: "Overview", path: "/components/popover" }, + { label: "Code", path: "/components/popover/code" }, + ]; + + return ( + + + + + + The popover component shows contextual content anchored to an element. It is used to present additional + information or actions without disrupting the interface. + + + + + {children} + + ); +}; + +export default PopoverPageHeading; diff --git a/apps/website/screens/components/popover/code/PopoverCodePage.tsx b/apps/website/screens/components/popover/code/PopoverCodePage.tsx new file mode 100644 index 000000000..d4931c7b5 --- /dev/null +++ b/apps/website/screens/components/popover/code/PopoverCodePage.tsx @@ -0,0 +1,145 @@ +import DocFooter from "@/common/DocFooter"; +import QuickNavContainer from "@/common/QuickNavContainer"; +import { DxcFlex, DxcTable } from "@dxc-technology/halstack-react"; +import { TableCode } from "@/common/Code"; +import StatusBadge from "@/common/StatusBadge"; +import Example from "@/common/example/Example"; +import uncontrolled from "./examples/uncontrolled"; +import controlled from "./examples/controlled"; + +const sections = [ + { + title: "Props", + content: ( + + + + Name + Type + Description + Default + + + + + actionToOpen + + 'click' | 'hover' + + Action that triggers the popover to open. + + 'click' + + + + align + + 'start' | 'center' | 'end' + + Alignment of the popover relative to the trigger element. + + 'center' + + + + asChild + + boolean + + Set to true if child controls the events. It will render the child directly without wrapping it. + - + + + + + + children + + + + React.ReactNode + + Element that triggers the popover and works as the anchor. + - + + + hasTip + + boolean + + Whether the popover should display a tip (arrow). + + false + + + + isOpen + + boolean + + Controlled open state of the popover. If it is left undefined, it will be uncontrolled. + - + + + onClose + + {"() => void"} + + + Callback function when the popover is opened. Used only in controlled mode and if the trigger lacks the + events to manage the controlled behavior. + + - + + + onOpen + + {"() => void"} + + Callback function when the popover is closed. + - + + + + + + popoverContent + + + + React.ReactNode + + Content to be displayed inside the popover. + - + + + side + + 'top' | 'bottom' | 'left' | 'right' + + Side of the trigger where the popover will appear. + + 'bottom' + + + + + ), + }, + { + title: "Examples", + subSections: [ + { title: "Uncontrolled Popover", content: }, + { title: "Controlled Popover", content: }, + ], + }, +]; + +const PopoverCodePage = () => ( + + + + +); + +export default PopoverCodePage; diff --git a/apps/website/screens/components/popover/code/examples/controlled.tsx b/apps/website/screens/components/popover/code/examples/controlled.tsx new file mode 100644 index 000000000..583895e82 --- /dev/null +++ b/apps/website/screens/components/popover/code/examples/controlled.tsx @@ -0,0 +1,27 @@ +import { DxcContainer, DxcParagraph, DxcPopover } from "@dxc-technology/halstack-react"; +import { useState } from "react"; + +const code = `() => { +const [isOpen, setIsOpen] = useState(false); + return ( + + setIsOpen(true)} + onClose={() => setIsOpen(false)} + popoverContent={Popover content.} + > + Click me to see the popover. + + + ); +}`; + +const scope = { + useState, + DxcContainer, + DxcParagraph, + DxcPopover, +}; + +export default { code, scope }; diff --git a/apps/website/screens/components/popover/code/examples/uncontrolled.tsx b/apps/website/screens/components/popover/code/examples/uncontrolled.tsx new file mode 100644 index 000000000..aa56f06bd --- /dev/null +++ b/apps/website/screens/components/popover/code/examples/uncontrolled.tsx @@ -0,0 +1,21 @@ +import { DxcContainer, DxcParagraph, DxcPopover } from "@dxc-technology/halstack-react"; + +const code = `() => { + return ( + + Popover content.}> + Hover me to see the popover. + + + ); +}`; + +const scope = { + DxcContainer, + DxcParagraph, + DxcPopover, +}; + +export default { code, scope }; diff --git a/apps/website/screens/components/popover/overview/PopoverOverviewPage.tsx b/apps/website/screens/components/popover/overview/PopoverOverviewPage.tsx new file mode 100644 index 000000000..67213bcfc --- /dev/null +++ b/apps/website/screens/components/popover/overview/PopoverOverviewPage.tsx @@ -0,0 +1,265 @@ +import { DxcBulletedList, DxcFlex, DxcParagraph, DxcTable } from "@dxc-technology/halstack-react"; +import QuickNavContainer from "@/common/QuickNavContainer"; +import DocFooter from "@/common/DocFooter"; +import Image from "@/common/Image"; +import Figure from "@/common/Figure"; +import anatomy from "./images/popover-anatomy.png"; +import positionVertical from "./images/popover-vertical-position.png"; +import positionHorizontal from "./images/popover-horizontal-position.png"; +import alignment from "./images/popover-alignment.png"; +import caret from "./images/popover-caret.png"; + +const sections = [ + { + title: "Introduction", + content: ( + <> + + The popover component is a non-blocking, contextual container that appears anchored to a + trigger element to display supplementary content. It is designed to support{" "} + secondary actions, contextual menus, and additional information without interrupting the + user's current flow or taking focus away from the main interface. + + + From a behavior perspective, the popover provides a flexible and robust positioning system. + It supports preferred positions, explicit alignment control, optional auto placement, and{" "} + dynamic positioning with automatic fallback, ensuring the content remains visible and usable + across different screen sizes and layouts. Thanks to this flexibility, popover can also be used as the + foundation for popup menus and contextual action panels. + + + ), + }, + { + title: "Anatomy", + content: ( + <> + Popover anatomy + + + Container: the structural wrapper that holds all popover content. It defines the surface, + spacing, elevation, and overall layout, ensuring the popover is visually separated from the background and + clearly associated with its trigger. + + + Arrow (caret) (optional): a small visual indicator that points to the trigger + element. It reinforces the contextual relationship between the popover and its origin and helps users + understand where the content comes from. + + + Content area: the main area where information and actions are displayed. It can contain + non-interactive content, interactive elements such as toggles or buttons, or structured elements like menu + items, depending on the use case. + + + + ), + }, + { + title: "Using popovers", + content: ( + <> + + The popover component is the foundation for multiple Halstack components that need to display + non-critical, contextual content. It is used internally by elements such as the{" "} + dropdown list, select options, and the date input calendar, providing a consistent way to + surface additional information or controls without interrupting the user's flow. + + + Beyond these composite components, a popover can also be used on its own to support a wide + range of use cases, including contextual action lists, quick configuration panels, lightweight tutorials, + small product cards, and popup menus. + + + A popover always requires a trigger element to appear. It is anchored to a reference element + in the interface, which defines its position and context. This trigger can be{" "} + interactive, such as a button or icon, or non-interactive, such as a text label or avatar, + depending on the use case and interaction model. + + + ), + subSections: [ + { + title: "Popover vs Tooltip vs Dialog", + content: ( + <> + + When designing interfaces with contextual content, it’s important to choose the right overlay pattern for + the job. Popover, tooltip, and dialog are all + mechanisms for displaying layered content, but they serve different purposes and interaction models. The + following table easily displays in which ways popovers relate and differ from tooltips and dialogs. + + + + + Component + Purpose + Interaction + Blocking + Typical content + When to use + + + + + + Popover + + Displays contextual content associated with a specific element + Can contain interactive and non-interactive elements + Non-blocking + Secondary actions, contextual menus, configuration panels, supplementary information + + Used to expose contextual functionality or information while keeping the user within the current + flow + + + + + Tooltip + + Provides brief, descriptive information about an element + Non-interactive + Non-blocking + Text hints + Used to clarify the meaning or purpose of an element without introducing actions + + + + Dialog + + Presents content that requires focused user attention + Interactive, often task driven + Blocking + Forms, confirmations, complex workflows + Used when a task or decision must be completed before returning to the main interface + + + + + ), + }, + { + title: "Position and alignment", + content: ( + <> + + The popover component is positioned relative to a trigger element and is designed to + adapt dynamically to the surrounding layout and available space. By default, the popover can be displayed + on any of the four main sides of its trigger: top, right, bottom, or left. The final + placement is determined by the preferred position defined by the consuming component, combined with the + available space within the viewport. + + + Popover supports dynamic positioning with automatic fallback, allowing it to change its + placement when there is not enough space in the preferred direction. This behavior ensures that the + popover remains visible, accessible, and fully contained within the browser boundaries, preventing content + from being clipped or rendered off-screen. + +
+ Popover vertical position +
+
+ Popover horizontal position +
+ + In addition to side placement, popover allows explicit control over alignment, making it + possible to align the popover to the start, center, or end of the trigger element. This enables more + precise layout control and supports a wide range of interface patterns, from simple contextual hints to + complex action panels. + + + This flexible placement system ensures that popovers remain{" "} + visually connected to their trigger, responsive to different screen sizes, and robust + across varying layouts and containers. + +
+ Popover alignment +
+ + ), + }, + { + title: "Caret", + content: ( + <> + + The popover component can be rendered with or without a caret, depending on the use case + and visual needs. The presence of a caret is optional and should be intentionally selected based on how + strongly the relationship between the popover and its trigger needs to be expressed. + + + + When used with a caret, the popover visually points to its trigger element, reinforcing + the contextual connection between both. This configuration is commonly applied in scenarios such as{" "} + contextual help, lightweight tutorials, onboarding cues, or explanatory content, where + it is important to clearly indicate the origin of the information and guide the user's attention. + + + When used without a caret, the popover behaves as a floating surface that is still + anchored to a trigger but does not explicitly point to it. This approach is typically preferred for{" "} + action oriented content, such as{" "} + popup menus, command lists, or configuration panels, where the emphasis is on the + content itself rather than on visually highlighting the trigger element. + + + + Both variants share the same positioning and interaction model. The choice to include a tip should be + driven by clarity, visual hierarchy, and the role the popover plays within the interface. + +
+ Popover caret +
+ + ), + }, + ], + }, + { + title: "Best practices", + content: ( + + + Let the popover size be defined by its content: Popover is a content driven container. Its + dimensions should adapt to what is inside rather than enforcing fixed sizes. As a general guideline, a popover + should not occupy more than 40% of the viewport in either width or height. If the content + exceeds this threshold, a different pattern such as dialog should be considered. + + + Use popover only for non-critical, contextual content: Popover is intended to{" "} + complement the interface, not to interrupt it. It should not be used for critical flows, + mandatory decisions, or complex tasks that require full user attention. + + + Keep content focused and lightweight: Popover works best with{" "} + concise, clearly scoped content. Long forms, dense data tables, or multi-step processes + reduce readability and weaken the contextual content. + + + Ensure a clear relationship with the trigger: The trigger element{" "} + should make it evident why the popover appears. The content should always feel directly + related to the element it is anchored to. + + + Avoid overusing popovers: Excessive use can clutter the interface and create cognitive + overload. Popovers should be reserved for cases where content truly benefits from staying visually connected + to a specific element. + + + Ensure accessibility and dismiss behavior: Popovers{" "} + should be easy to dismiss and should not trap the user. Clear focus management and + predictable closing behaviors are essential to maintain usability and accessibility. + + + ), + }, +]; + +const PopoverOverviewPage = () => ( + + + + +); + +export default PopoverOverviewPage; diff --git a/apps/website/screens/components/popover/overview/images/popover-alignment.png b/apps/website/screens/components/popover/overview/images/popover-alignment.png new file mode 100644 index 000000000..feeb8063c Binary files /dev/null and b/apps/website/screens/components/popover/overview/images/popover-alignment.png differ diff --git a/apps/website/screens/components/popover/overview/images/popover-anatomy.png b/apps/website/screens/components/popover/overview/images/popover-anatomy.png new file mode 100644 index 000000000..21f120d78 Binary files /dev/null and b/apps/website/screens/components/popover/overview/images/popover-anatomy.png differ diff --git a/apps/website/screens/components/popover/overview/images/popover-caret.png b/apps/website/screens/components/popover/overview/images/popover-caret.png new file mode 100644 index 000000000..fb0a55a04 Binary files /dev/null and b/apps/website/screens/components/popover/overview/images/popover-caret.png differ diff --git a/apps/website/screens/components/popover/overview/images/popover-horizontal-position.png b/apps/website/screens/components/popover/overview/images/popover-horizontal-position.png new file mode 100644 index 000000000..4cd5c476a Binary files /dev/null and b/apps/website/screens/components/popover/overview/images/popover-horizontal-position.png differ diff --git a/apps/website/screens/components/popover/overview/images/popover-vertical-position.png b/apps/website/screens/components/popover/overview/images/popover-vertical-position.png new file mode 100644 index 000000000..7f852a845 Binary files /dev/null and b/apps/website/screens/components/popover/overview/images/popover-vertical-position.png differ diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts index 78c3eb941..678a42ca8 100644 --- a/packages/lib/src/index.ts +++ b/packages/lib/src/index.ts @@ -32,6 +32,7 @@ export { default as DxcNumberInput } from "./number-input/NumberInput"; export { default as DxcPaginator } from "./paginator/Paginator"; export { default as DxcParagraph } from "./paragraph/Paragraph"; export { default as DxcPasswordInput } from "./password-input/PasswordInput"; +export { default as DxcPopover } from "./popover/Popover"; export { default as DxcProgressBar } from "./progress-bar/ProgressBar"; export { default as DxcQuickNav } from "./quick-nav/QuickNav"; export { default as DxcRadioGroup } from "./radio-group/RadioGroup"; diff --git a/packages/lib/src/popover/Popover.accessibility.test.tsx b/packages/lib/src/popover/Popover.accessibility.test.tsx new file mode 100644 index 000000000..ff5f4e714 --- /dev/null +++ b/packages/lib/src/popover/Popover.accessibility.test.tsx @@ -0,0 +1,15 @@ +import { render } from "@testing-library/react"; +import { axe } from "../../test/accessibility/axe-helper"; +import DxcPopover from "./Popover"; + +describe("Popover component accessibility tests", () => { + it("Should not have basic accessibility issues", async () => { + const { container } = render( + Popover content}> + Trigger + + ); + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); +}); diff --git a/packages/lib/src/popover/Popover.stories.tsx b/packages/lib/src/popover/Popover.stories.tsx new file mode 100644 index 000000000..7a9bfcaa0 --- /dev/null +++ b/packages/lib/src/popover/Popover.stories.tsx @@ -0,0 +1,148 @@ +import { Meta, StoryObj } from "@storybook/react-vite"; +import ExampleContainer from "../../.storybook/components/ExampleContainer"; +import Title from "../../.storybook/components/Title"; +import DxcPopover from "./Popover"; +import DxcParagraph from "../paragraph/Paragraph"; +import DxcContainer from "../container/Container"; +import DxcButton from "../button/Button"; +import { useEffect } from "react"; +import DxcFlex from "../flex/Flex"; + +export default { + title: "Popover", + component: DxcPopover, + decorators: [ + (Story) => { + useEffect(() => { + document.body.style.background = "var(--color-bg-neutral-light) "; + }, []); + + return ; + }, + ], +} satisfies Meta; + +const popoverContent = ( + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla id tortor sit amet velit auctor cursus id eget + nisl. Vivamus luctus egestas eros, at mattis libero eleifend ac. Integer vel urna rutrum, pretium arcu dignissim, + fringilla turpis. Nullam luctus odio quis magna finibus, a pharetra magna euismod. Nullam efficitur semper + pellentesque. Nulla eget arcu ac diam fringilla vehicula. In imperdiet nisl hendrerit, elementum metus eu, ornare + risus. Aenean neque nibh, vestibulum vitae elit vel, imperdiet suscipit leo. Curabitur blandit iaculis pretium. + Fusce id imperdiet dui, ut laoreet justo. Sed maximus sollicitudin ipsum, et varius massa condimentum eget. + Vivamus id mauris et nisl mattis consequat et id lectus. Quisque mollis lacinia nisl. Suspendisse sed quam + tincidunt, commodo dolor vel, tincidunt nisl. + + + +); + +const Popover = () => { + return ( + <> + + + <DxcPopover isOpen popoverContent={popoverContent}> + <DxcParagraph>Hover or click to open the popover</DxcParagraph> + </DxcPopover> + </ExampleContainer> + <ExampleContainer expanded> + <Title title="Popover with tip" level={4} /> + <DxcPopover isOpen hasTip popoverContent={popoverContent}> + <DxcParagraph>Hover or click to open the popover</DxcParagraph> + </DxcPopover> + </ExampleContainer> + <ExampleContainer expanded> + <DxcPopover isOpen hasTip popoverContent={popoverContent} side="right" align="start"> + <DxcParagraph>Hover or click to open the popover</DxcParagraph> + </DxcPopover> + </ExampleContainer> + <ExampleContainer expanded> + <Title title="Side Popover" level={4} /> + <DxcFlex alignItems="center" justifyContent="center"> + <DxcPopover isOpen side="right" popoverContent={popoverContent} asChild> + <button>right</button> + </DxcPopover> + </DxcFlex> + </ExampleContainer> + <ExampleContainer expanded> + <DxcFlex alignItems="center" justifyContent="center"> + <DxcPopover isOpen side="right" popoverContent={popoverContent} align="start" asChild> + <button>right</button> + </DxcPopover> + </DxcFlex> + </ExampleContainer> + <ExampleContainer expanded> + <DxcFlex alignItems="center" justifyContent="center"> + <DxcPopover isOpen side="right" popoverContent={popoverContent} align="end" asChild> + <button>right</button> + </DxcPopover> + </DxcFlex> + </ExampleContainer> + <ExampleContainer expanded> + <DxcFlex alignItems="center" justifyContent="center"> + <DxcPopover isOpen side="left" popoverContent={popoverContent} asChild> + <button>left</button> + </DxcPopover> + </DxcFlex> + </ExampleContainer> + <ExampleContainer expanded> + <DxcFlex alignItems="center" justifyContent="center"> + <DxcPopover isOpen side="left" popoverContent={popoverContent} align="start" asChild> + <button>left</button> + </DxcPopover> + </DxcFlex> + </ExampleContainer> + <ExampleContainer expanded> + <DxcFlex alignItems="center" justifyContent="center"> + <DxcPopover isOpen side="left" popoverContent={popoverContent} align="end" asChild> + <button>left</button> + </DxcPopover> + </DxcFlex> + </ExampleContainer> + <ExampleContainer expanded> + <DxcFlex alignItems="center" justifyContent="center"> + <DxcPopover isOpen side="top" popoverContent={popoverContent} asChild> + <button>top</button> + </DxcPopover> + </DxcFlex> + </ExampleContainer> + <ExampleContainer expanded> + <DxcFlex alignItems="center" justifyContent="center"> + <DxcPopover isOpen side="top" popoverContent={popoverContent} align="start" asChild> + <button>top</button> + </DxcPopover> + </DxcFlex> + </ExampleContainer> + <ExampleContainer expanded> + <DxcFlex alignItems="center" justifyContent="center"> + <DxcPopover isOpen side="top" popoverContent={popoverContent} align="end" asChild> + <button>top</button> + </DxcPopover> + </DxcFlex> + </ExampleContainer> + <ExampleContainer expanded> + <DxcPopover isOpen side="left" popoverContent={popoverContent} asChild> + <button>left</button> + </DxcPopover> + </ExampleContainer> + <ExampleContainer expanded> + <DxcPopover isOpen side="left" popoverContent={popoverContent} align="start" asChild> + <button>left</button> + </DxcPopover> + </ExampleContainer> + <ExampleContainer expanded> + <DxcPopover isOpen side="left" popoverContent={popoverContent} align="end" asChild> + <button>left</button> + </DxcPopover> + </ExampleContainer> + </> + ); +}; + +type Story = StoryObj<typeof DxcPopover>; + +export const Chromatic: Story = { + render: Popover, +}; diff --git a/packages/lib/src/popover/Popover.test.tsx b/packages/lib/src/popover/Popover.test.tsx new file mode 100644 index 000000000..5eb428b48 --- /dev/null +++ b/packages/lib/src/popover/Popover.test.tsx @@ -0,0 +1,80 @@ +import { render } from "@testing-library/react"; +import DxcPopover from "./Popover"; +import userEvent from "@testing-library/user-event"; +import DxcButton from "../button/Button"; + +describe("Popover component tests", () => { + test("The component renders properly onClick", () => { + const { getByText } = render(<DxcPopover popoverContent={<div>Popover content</div>}>Trigger</DxcPopover>); + expect(getByText("Trigger")).toBeTruthy(); + userEvent.click(getByText("Trigger")); + expect(getByText("Popover content")).toBeTruthy(); + }); + + test("The component renders properly onHover", () => { + const { getByText, queryByText } = render( + <DxcPopover actionToOpen="hover" popoverContent={<div>Popover content</div>}> + Trigger + </DxcPopover> + ); + expect(queryByText("Popover content")).toBeFalsy(); + expect(getByText("Trigger")).toBeTruthy(); + userEvent.hover(getByText("Trigger")); + expect(getByText("Popover content")).toBeTruthy(); + }); + + test("The component manages events correctly when controlled click onOpen", () => { + const onOpen = jest.fn(); + const { getByText, queryByText } = render( + <DxcPopover isOpen={false} onOpen={onOpen} popoverContent={<div>Popover content</div>}> + Trigger + </DxcPopover> + ); + expect(queryByText("Popover content")).toBeFalsy(); + expect(getByText("Trigger")).toBeTruthy(); + userEvent.click(getByText("Trigger")); + expect(onOpen).toHaveBeenCalled(); + }); + + test("The component manages events correctly when controlled hover onOpen", () => { + const onOpen = jest.fn(); + const { getByText, queryByText } = render( + <DxcPopover isOpen={false} actionToOpen="hover" onOpen={onOpen} popoverContent={<div>Popover content</div>}> + Trigger + </DxcPopover> + ); + expect(queryByText("Popover content")).toBeFalsy(); + expect(getByText("Trigger")).toBeTruthy(); + userEvent.hover(getByText("Trigger")); + expect(onOpen).toHaveBeenCalled(); + }); + + test("The component manages events correctly when controlled click onClose", () => { + const onClose = jest.fn(); + const { getByText, queryByText } = render( + <> + <DxcPopover isOpen={true} onClose={onClose} popoverContent={<div>Popover content</div>}> + Trigger + </DxcPopover> + <DxcButton label="Focus out" /> + </> + ); + expect(queryByText("Trigger")).toBeTruthy(); + expect(getByText("Popover content")).toBeTruthy(); + userEvent.click(getByText("Focus out")); + expect(onClose).toHaveBeenCalled(); + }); + + test("The component manages events correctly when controlled onHover", () => { + const onClose = jest.fn(); + const { getByText, queryByText } = render( + <DxcPopover isOpen={true} actionToOpen="hover" onClose={onClose} popoverContent={<div>Popover content</div>}> + Trigger + </DxcPopover> + ); + expect(queryByText("Trigger")).toBeTruthy(); + expect(getByText("Popover content")).toBeTruthy(); + userEvent.unhover(getByText("Trigger")); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/packages/lib/src/popover/Popover.tsx b/packages/lib/src/popover/Popover.tsx new file mode 100644 index 000000000..b6435244c --- /dev/null +++ b/packages/lib/src/popover/Popover.tsx @@ -0,0 +1,104 @@ +import styled from "@emotion/styled"; +import { useId, useRef, useState } from "react"; +import * as Popover from "@radix-ui/react-popover"; +import { PopoverPropsType } from "./types"; + +const PopoverContent = styled.div` + box-sizing: border-box; + border-radius: var(--border-radius-m); + box-shadow: var(--shadow-400); + padding: var(--spacing-gap-s); + background-color: var(--color-bg-neutral-lightest); +`; + +const handleTrigger = ( + isControlled: boolean, + setOpened: React.Dispatch<React.SetStateAction<boolean>>, + open: boolean, + onTrigger?: () => void +) => { + if (isControlled) { + if (onTrigger) { + onTrigger(); + } + } else { + setOpened(open); + } +}; + +const DxcPopover = ({ + actionToOpen = "click", + align = "center", + asChild, + children, + hasTip = false, + isOpen, + onOpen, + onClose, + popoverContent, + side = "bottom", +}: PopoverPropsType): JSX.Element => { + const popOverId = `popover-${useId()}`; + const isControlled = useRef(isOpen !== undefined); + + const [opened, setOpened] = useState(false); + + return ( + <> + <Popover.Root open={isControlled.current ? isOpen : opened}> + <Popover.Trigger aria-controls={undefined} asChild> + {asChild ? ( + children + ) : ( + <div + role="button" + style={{ width: "fit-content" }} + onClick={ + actionToOpen === "click" + ? () => handleTrigger(isControlled.current, setOpened, true, onOpen) + : undefined + } + onMouseEnter={ + actionToOpen === "hover" + ? () => handleTrigger(isControlled.current, setOpened, true, onOpen) + : undefined + } + onMouseLeave={ + actionToOpen === "hover" + ? () => handleTrigger(isControlled.current, setOpened, false, onClose) + : undefined + } + > + {children} + </div> + )} + </Popover.Trigger> + <Popover.Portal container={document.getElementById(`${popOverId}-portal`)}> + <Popover.Content + aria-label="Popover content" + align={align} + side={side} + sideOffset={4} + style={{ zIndex: "var(--z-contextualMenu)" }} + onInteractOutside={() => handleTrigger(isControlled.current, setOpened, false, onClose)} + onEscapeKeyDown={() => handleTrigger(isControlled.current, setOpened, false, onClose)} + onMouseEnter={ + actionToOpen === "hover" ? () => handleTrigger(isControlled.current, setOpened, true, onOpen) : undefined + } + onMouseLeave={ + actionToOpen === "hover" + ? () => handleTrigger(isControlled.current, setOpened, false, onClose) + : undefined + } + > + <PopoverContent id={popOverId}>{popoverContent}</PopoverContent> + {hasTip && <Popover.Arrow style={{ fill: "var(--color-bg-neutral-lightest)" }} />} + </Popover.Content> + </Popover.Portal> + </Popover.Root> + <div id={`${popOverId}-portal`} /> + </> + ); +}; + +export default DxcPopover; diff --git a/packages/lib/src/popover/types.ts b/packages/lib/src/popover/types.ts new file mode 100644 index 000000000..c4809a54c --- /dev/null +++ b/packages/lib/src/popover/types.ts @@ -0,0 +1,25 @@ +import React from "react"; + +export type PopoverPropsType = { + /** Action that triggers the popover to open. */ + actionToOpen?: "click" | "hover"; + /** Alignment of the popover relative to the trigger element. */ + align?: "start" | "center" | "end"; + /** Set to true if child controls the events. It will render the child directly without wrapping it. */ + asChild?: boolean; + /** Element that triggers the popover and works as the anchor. */ + children: React.ReactNode; + /** Whether the popover should display a tip (arrow). */ + hasTip?: boolean; + /** Controlled open state of the popover. If it is left undefined, it will be uncontrolled. */ + isOpen?: boolean; + /** Callback function when the popover is opened. + * Used only in controlled mode and if the trigger lacks the events to manage the controlled behavior. */ + onOpen?: () => void; + /** Callback function when the popover is closed. */ + onClose?: () => void; + /** Content to be displayed inside the popover. */ + popoverContent: React.ReactNode; + /** Side of the trigger where the popover will appear. */ + side?: "top" | "bottom" | "left" | "right"; +};