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..d37ccfd --- /dev/null +++ b/src/components/popover/HeadlessPopover.tsx @@ -0,0 +1,154 @@ +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 { + trigger?: ReactNode; + panel?: ReactNode | ((close: () => void) => ReactNode); + className?: string; + buttonClassName?: string; + panelClassName?: string; + panelStyle?: React.CSSProperties; + buttonAs?: React.ElementType; + isAnimated?: boolean; + isStatic?: 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'; + +const BUTTON_CONTAINER_STYLE = { lineHeight: 0 } as const; + +/** + * HeadlessPopover component - A flexible popover wrapper around HeadlessUI. + * + * ## Two usage modes: + * + * ### 1. Simple Mode (use trigger + panel props): + * ```tsx + * Click me} + * panel={
Content
} + * /> + * ``` + * + * ### 2. Render Props Mode (use children for full control): + * ```tsx + * + * {({ open, close, Button, Panel }) => ( + * <> + * + * Custom Panel + * + * )} + * + * ``` + * + * **Note:** If `children` prop is provided, all other props (trigger, panel, etc.) are ignored. + * + * @property {ReactNode} [trigger] + * - 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. + * + * @property {string} [buttonClassName] + * - Custom classes for the trigger button. + * + * @property {string} [panelClassName] + * - 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 (e.g., 'div', CustomComponent). + * + * @property {boolean} [isAnimated=true] + * - Whether to use transition animations when opening/closing. + * + * @property {boolean} [isStatic=false] + * - Whether to keep the panel mounted in the DOM even when closed (static mode). + * + * @property {(props: HeadlessPopoverRenderProps) => ReactNode} [children] + * - Render prop function for advanced customization. When provided, overrides all other props. + * + * @returns {JSX.Element} + * - The rendered HeadlessPopover component. + */ +export default function HeadlessPopover({ + trigger, + panel, + className = '', + buttonClassName = '', + panelClassName, + panelStyle, + buttonAs, + isAnimated = true, + isStatic = false, + children, +}: Readonly): JSX.Element { + if (children) { + return ( + + {({ open, close }: { open: boolean; close: () => void }) => <>{children({ open, close, Button: HPopover.Button, Panel: HPopover.Panel })}} + + ); + } + + const panelElement = ( + + {({ close }: { close: () => void }) => ( + <>{typeof panel === 'function' ? panel(close) : panel} + )} + + ); + + const containerClassName = `relative ${className}`; + const defaultButtonClassName = [ + 'cursor-pointer', + 'outline-none', + 'focus-visible:outline-2', + 'focus-visible:outline-offset-2', + 'focus-visible:outline-primary' + ].join(' '); + const finalButtonClassName = `${defaultButtonClassName} ${buttonClassName}`; + + return ( + + + {trigger} + + + {isAnimated ? ( + + {panelElement} + + ) : ( + panelElement + )} + + ); +} diff --git a/src/components/popover/__test__/HeadlessPopover.test.tsx b/src/components/popover/__test__/HeadlessPopover.test.tsx new file mode 100644 index 0000000..eb897cd --- /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" + buttonClassName="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"