Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@internxt/ui",
"version": "0.1.1",
"version": "0.1.2",
"description": "Library of Internxt components",
"repository": {
"type": "git",
Expand Down Expand Up @@ -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",
Expand Down
121 changes: 121 additions & 0 deletions src/components/popover/HeadlessPopover.tsx
Original file line number Diff line number Diff line change
@@ -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<HeadlessPopoverProps>): JSX.Element {
if (children) {
return (
<HPopover className={className}>
{({ open, close }: { open: boolean; close: () => void }) => <>{children({ open, close, Button: HPopover.Button, Panel: HPopover.Panel })}</>}
</HPopover>
);
}

const PanelContent = ({ close }: { close: () => void }) => (
<>{typeof panel === 'function' ? panel(close) : panel}</>
);

const Panel = (
<HPopover.Panel
className={classPanel || DEFAULT_PANEL_CLASS}
style={panelStyle}
static={shouldAlwaysShow}
>
{({ close }: { close: () => void }) => <PanelContent close={close} />}
</HPopover.Panel>
);

return (
<HPopover style={{ lineHeight: 0 }} className={`relative ${className}`}>
<HPopover.Button as={buttonAs} className={`cursor-pointer outline-none ${classButton}`}>
{childrenButton}
</HPopover.Button>

{shouldUseTransition ? (
<Transition
enter="transition duration-100 ease-out"
enterFrom="scale-95 opacity-0"
enterTo="scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="scale-100 opacity-100"
leaveTo="scale-95 opacity-0"
className="z-50"
>
{Panel}
</Transition>
) : (
Panel
)}
</HPopover>
);
}
74 changes: 74 additions & 0 deletions src/components/popover/__test__/HeadlessPopover.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<HeadlessPopover
childrenButton={<span>Toggle</span>}
panel={(close) => <div>Panel Content</div>}
/>
);

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(
<HeadlessPopover
childrenButton={<span>Toggle</span>}
panel={(close) => <button onClick={close}>Close</button>}
/>
);

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(
<HeadlessPopover
childrenButton={<span>Toggle</span>}
panel={<div>Content</div>}
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(
<HeadlessPopover>
{({ open, close, Button, Panel }) => (
<>
<Button>Open Menu</Button>
<Panel>
<button onClick={close}>Close Menu</button>
{open && <div>Menu is open</div>}
</Panel>
</>
)}
</HeadlessPopover>
);

expect(queryByText('Menu is open')).not.toBeInTheDocument();

fireEvent.click(getByText('Open Menu'));
expect(getByText('Menu is open')).toBeInTheDocument();
});
});
7 changes: 7 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down