diff --git a/package.json b/package.json index ac063ab..f326a95 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@internxt/ui", - "version": "0.1.1", + "version": "0.1.2", "description": "Library of Internxt components", "repository": { "type": "git", @@ -87,6 +87,7 @@ "storybook:build": "storybook build" }, "dependencies": { + "@headlessui/react": "1.7.5", "@internxt/css-config": "1.1.0", "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-switch": "^1.2.6", diff --git a/src/components/popover/HeadlessPopover.tsx b/src/components/popover/HeadlessPopover.tsx new file mode 100644 index 0000000..773d206 --- /dev/null +++ b/src/components/popover/HeadlessPopover.tsx @@ -0,0 +1,121 @@ +import { Popover as HPopover, Transition } from '@headlessui/react'; +import { ReactNode } from 'react'; + +export interface HeadlessPopoverRenderProps { + open: boolean; + close: () => void; + Button: typeof HPopover.Button; + Panel: typeof HPopover.Panel; +} + +interface HeadlessPopoverProps { + childrenButton?: ReactNode; + panel?: ReactNode | ((close: () => void) => ReactNode); + className?: string; + classButton?: string; + classPanel?: string; + panelStyle?: React.CSSProperties; + buttonAs?: React.ElementType; + shouldUseTransition?: boolean; + shouldAlwaysShow?: boolean; + children?: (props: HeadlessPopoverRenderProps) => ReactNode; +} + +const DEFAULT_PANEL_CLASS = 'absolute right-0 z-50 mt-1 rounded-md border border-gray-10 bg-surface py-1.5 shadow-subtle dark:bg-gray-5'; + +/** + * HeadlessPopover component + * + * @property {ReactNode} childrenButton + * - The content to be displayed inside the trigger button. + * + * @property {ReactNode | ((close: () => void) => ReactNode)} panel + * - The content to be displayed inside the popover panel. + * Can be a ReactNode or a function that receives a `close` function as a parameter. + * + * @property {string} [className] + * - Additional custom classes for the outermost container of the popover. + * Can be used for positioning or adding custom styles. + * + * @property {string} [classButton] + * - Custom classes for the trigger button. + * + * @property {string} [classPanel] + * - Custom classes for the panel container. + * + * @property {React.CSSProperties} [panelStyle] + * - Inline styles for the panel. + * + * @property {React.ElementType} [buttonAs] + * - Custom element type for the button. + * + * @property {boolean} [shouldUseTransition=true] + * - Whether to use transition animations. + * + * @property {boolean} [shouldAlwaysShow=false] + * - Whether to always show the panel (static mode). + * + * @property {(props: HeadlessPopoverRenderProps) => ReactNode} [children] + * - Render prop function for advanced customization. + * + * @returns {JSX.Element} + * - The rendered HeadlessPopover component. + */ +export default function HeadlessPopover({ + childrenButton, + panel, + className = '', + classButton = '', + classPanel, + panelStyle, + buttonAs, + shouldUseTransition = true, + shouldAlwaysShow = false, + children, +}: Readonly): JSX.Element { + if (children) { + return ( + + {({ open, close }: { open: boolean; close: () => void }) => <>{children({ open, close, Button: HPopover.Button, Panel: HPopover.Panel })}} + + ); + } + + const PanelContent = ({ close }: { close: () => void }) => ( + <>{typeof panel === 'function' ? panel(close) : panel} + ); + + const Panel = ( + + {({ close }: { close: () => void }) => } + + ); + + return ( + + + {childrenButton} + + + {shouldUseTransition ? ( + + {Panel} + + ) : ( + Panel + )} + + ); +} diff --git a/src/components/popover/__test__/HeadlessPopover.test.tsx b/src/components/popover/__test__/HeadlessPopover.test.tsx new file mode 100644 index 0000000..60717c5 --- /dev/null +++ b/src/components/popover/__test__/HeadlessPopover.test.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import HeadlessPopover from '../HeadlessPopover'; +import { describe, it, expect } from 'vitest'; + +describe('HeadlessPopover', () => { + it('shows and hides panel when button is clicked', () => { + const { getByText, queryByText } = render( + Toggle} + panel={(close) =>
Panel Content
} + /> + ); + + expect(queryByText('Panel Content')).not.toBeInTheDocument(); + + fireEvent.click(getByText('Toggle')); + expect(getByText('Panel Content')).toBeInTheDocument(); + + fireEvent.click(getByText('Toggle')); + expect(queryByText('Panel Content')).not.toBeInTheDocument(); + }); + + it('closes when close button inside panel is clicked', () => { + const { getByText, queryByText } = render( + Toggle} + panel={(close) => } + /> + ); + + fireEvent.click(getByText('Toggle')); + expect(getByText('Close')).toBeInTheDocument(); + + fireEvent.click(getByText('Close')); + expect(queryByText('Close')).not.toBeInTheDocument(); + }); + + it('applies custom styling classes', () => { + const { container } = render( + Toggle} + panel={
Content
} + className="custom-container" + classButton="custom-button" + /> + ); + + expect(container.firstChild).toHaveClass('custom-container'); + const button = container.querySelector('button'); + expect(button).toHaveClass('custom-button'); + }); + + it('works with custom children function', () => { + const { getByText, queryByText } = render( + + {({ open, close, Button, Panel }) => ( + <> + + + + {open &&
Menu is open
} +
+ + )} +
+ ); + + expect(queryByText('Menu is open')).not.toBeInTheDocument(); + + fireEvent.click(getByText('Open Menu')); + expect(getByText('Menu is open')).toBeInTheDocument(); + }); +}); diff --git a/yarn.lock b/yarn.lock index d67ef86..26c30b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -706,6 +706,13 @@ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.10.tgz#a2a1e3812d14525f725d011a73eceb41fef5bc1c" integrity sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ== +"@headlessui/react@1.7.5": + version "1.7.5" + resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-1.7.5.tgz#c8864b0731d95dbb34aa6b3a60d0ee9ae6f8a7ca" + integrity sha512-UZSxOfA0CYKO7QDT5OGlFvesvlR1SKkawwSjwQJwt7XQItpzRKdE3ZUQxHcg4LEz3C0Wler2s9psdb872ynwrQ== + dependencies: + client-only "^0.0.1" + "@humanfs/core@^0.19.1": version "0.19.1" resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77"