From 592893b446c880e13e42ad03d77778111382e0fa Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Fri, 23 Jan 2026 01:18:43 -0400 Subject: [PATCH 1/3] feat: add HeadlessPopover component with accessibility support Introduces a new HeadlessPopover component built with @headlessui/react, providing enhanced accessibility features including ARIA support, keyboard navigation, and focus management. The component supports flexible APIs including render props, custom styling, transitions, and static mode. --- package.json | 1 + src/components/popover/HeadlessPopover.tsx | 121 ++++++++++++++++++ .../popover/__test__/HeadlessPopover.test.tsx | 73 +++++++++++ yarn.lock | 7 + 4 files changed, 202 insertions(+) create mode 100644 src/components/popover/HeadlessPopover.tsx create mode 100644 src/components/popover/__test__/HeadlessPopover.test.tsx diff --git a/package.json b/package.json index ac063ab..88a3a18 100644 --- a/package.json +++ b/package.json @@ -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..60bccc2 --- /dev/null +++ b/src/components/popover/__test__/HeadlessPopover.test.tsx @@ -0,0 +1,73 @@ +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 { getByText, container } = render( + Toggle} + panel={
Content
} + className="custom-container" + classButton="custom-button" + /> + ); + + expect(container.firstChild).toHaveClass('custom-container'); + expect(getByText('Toggle')).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" From 9c64257487d1c6e4746d9ed8442c25f5100d53a7 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Fri, 23 Jan 2026 01:23:56 -0400 Subject: [PATCH 2/3] chore: bump version to 0.1.2 and update HeadlessPopover test for button class --- package.json | 2 +- src/components/popover/__test__/HeadlessPopover.test.tsx | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 88a3a18..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", diff --git a/src/components/popover/__test__/HeadlessPopover.test.tsx b/src/components/popover/__test__/HeadlessPopover.test.tsx index 60bccc2..60717c5 100644 --- a/src/components/popover/__test__/HeadlessPopover.test.tsx +++ b/src/components/popover/__test__/HeadlessPopover.test.tsx @@ -37,7 +37,7 @@ describe('HeadlessPopover', () => { }); it('applies custom styling classes', () => { - const { getByText, container } = render( + const { container } = render( Toggle} panel={
Content
} @@ -47,7 +47,8 @@ describe('HeadlessPopover', () => { ); expect(container.firstChild).toHaveClass('custom-container'); - expect(getByText('Toggle')).toHaveClass('custom-button'); + const button = container.querySelector('button'); + expect(button).toHaveClass('custom-button'); }); it('works with custom children function', () => { From 32f7700671375c8441bd3efb99beb14d4a927d88 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 10 Feb 2026 00:21:29 -0400 Subject: [PATCH 3/3] refactor(HeadlessPopover): improve naming conventions and code clarity --- src/components/popover/HeadlessPopover.tsx | 105 ++++++++++++------ .../popover/__test__/HeadlessPopover.test.tsx | 8 +- 2 files changed, 73 insertions(+), 40 deletions(-) diff --git a/src/components/popover/HeadlessPopover.tsx b/src/components/popover/HeadlessPopover.tsx index 773d206..d37ccfd 100644 --- a/src/components/popover/HeadlessPopover.tsx +++ b/src/components/popover/HeadlessPopover.tsx @@ -9,68 +9,93 @@ export interface HeadlessPopoverRenderProps { } interface HeadlessPopoverProps { - childrenButton?: ReactNode; + trigger?: ReactNode; panel?: ReactNode | ((close: () => void) => ReactNode); className?: string; - classButton?: string; - classPanel?: string; + buttonClassName?: string; + panelClassName?: string; panelStyle?: React.CSSProperties; buttonAs?: React.ElementType; - shouldUseTransition?: boolean; - shouldAlwaysShow?: boolean; + 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 + * 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} childrenButton + * @property {ReactNode} [trigger] * - The content to be displayed inside the trigger button. * - * @property {ReactNode | ((close: () => void) => ReactNode)} panel + * @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] + * @property {string} [buttonClassName] * - Custom classes for the trigger button. * - * @property {string} [classPanel] + * @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. + * - Custom element type for the button (e.g., 'div', CustomComponent). * - * @property {boolean} [shouldUseTransition=true] - * - Whether to use transition animations. + * @property {boolean} [isAnimated=true] + * - Whether to use transition animations when opening/closing. * - * @property {boolean} [shouldAlwaysShow=false] - * - Whether to always show the panel (static mode). + * @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. + * - Render prop function for advanced customization. When provided, overrides all other props. * * @returns {JSX.Element} * - The rendered HeadlessPopover component. */ export default function HeadlessPopover({ - childrenButton, + trigger, panel, className = '', - classButton = '', - classPanel, + buttonClassName = '', + panelClassName, panelStyle, buttonAs, - shouldUseTransition = true, - shouldAlwaysShow = false, + isAnimated = true, + isStatic = false, children, }: Readonly): JSX.Element { if (children) { @@ -81,27 +106,35 @@ export default function HeadlessPopover({ ); } - const PanelContent = ({ close }: { close: () => void }) => ( - <>{typeof panel === 'function' ? panel(close) : panel} - ); - - const Panel = ( + const panelElement = ( - {({ close }: { close: () => void }) => } + {({ 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 ( - - - {childrenButton} + + + {trigger} - {shouldUseTransition ? ( + {isAnimated ? ( - {Panel} + {panelElement} ) : ( - Panel + panelElement )} ); diff --git a/src/components/popover/__test__/HeadlessPopover.test.tsx b/src/components/popover/__test__/HeadlessPopover.test.tsx index 60717c5..eb897cd 100644 --- a/src/components/popover/__test__/HeadlessPopover.test.tsx +++ b/src/components/popover/__test__/HeadlessPopover.test.tsx @@ -7,7 +7,7 @@ describe('HeadlessPopover', () => { it('shows and hides panel when button is clicked', () => { const { getByText, queryByText } = render( Toggle} + trigger={Toggle} panel={(close) =>
Panel Content
} /> ); @@ -24,7 +24,7 @@ describe('HeadlessPopover', () => { it('closes when close button inside panel is clicked', () => { const { getByText, queryByText } = render( Toggle} + trigger={Toggle} panel={(close) => } /> ); @@ -39,10 +39,10 @@ describe('HeadlessPopover', () => { it('applies custom styling classes', () => { const { container } = render( Toggle} + trigger={Toggle} panel={
Content
} className="custom-container" - classButton="custom-button" + buttonClassName="custom-button" /> );