Skip to content
Open
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",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m not totally convinced about adding a new library for the headless setup, so I created a separate popover component instead of changing the existing one. Curious what you think

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fewer dependencies on third parties, the better. Why use this library compared to what we already have?

"@internxt/css-config": "1.1.0",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-switch": "^1.2.6",
Expand Down
154 changes: 154 additions & 0 deletions src/components/popover/HeadlessPopover.tsx
Original file line number Diff line number Diff line change
@@ -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
* <HeadlessPopover
* trigger={<button>Click me</button>}
* panel={<div>Content</div>}
* />
* ```
*
* ### 2. Render Props Mode (use children for full control):
* ```tsx
* <HeadlessPopover>
* {({ open, close, Button, Panel }) => (
* <>
* <Button>Custom Button</Button>
* <Panel>Custom Panel</Panel>
* </>
* )}
* </HeadlessPopover>
* ```
*
* **Note:** If `children` prop is provided, all other props (trigger, panel, etc.) are ignored.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that classname it applies

*
* @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({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is not totally headless because it already has default style :O

trigger,
panel,
className = '',
buttonClassName = '',
panelClassName,
panelStyle,
buttonAs,
isAnimated = true,
isStatic = 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 panelElement = (
<HPopover.Panel
className={panelClassName || DEFAULT_PANEL_CLASS}
style={panelStyle}
static={isStatic}
>
{({ close }: { close: () => void }) => (
<>{typeof panel === 'function' ? panel(close) : panel}</>
)}
</HPopover.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 (
<HPopover style={BUTTON_CONTAINER_STYLE} className={containerClassName}>
<HPopover.Button as={buttonAs} className={finalButtonClassName}>
{trigger}
</HPopover.Button>

{isAnimated ? (
<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"
>
{panelElement}
</Transition>
) : (
panelElement
)}
</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
trigger={<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
trigger={<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
trigger={<span>Toggle</span>}
panel={<div>Content</div>}
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(
<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