diff --git a/docs/app/docs/components/popover/docs/anatomy.tsx b/docs/app/docs/components/popover/docs/anatomy.tsx new file mode 100644 index 000000000..c2c6e28e0 --- /dev/null +++ b/docs/app/docs/components/popover/docs/anatomy.tsx @@ -0,0 +1,15 @@ +import Popover from "@radui/ui/Popover"; + +export default () => { + return ( + + + {/* Your trigger element */} + + + + {/* Your popover content */} + + + ); +}; diff --git a/docs/app/docs/components/popover/docs/codeUsage.js b/docs/app/docs/components/popover/docs/codeUsage.js new file mode 100644 index 000000000..245da3121 --- /dev/null +++ b/docs/app/docs/components/popover/docs/codeUsage.js @@ -0,0 +1,52 @@ +import { getSourceCodeFromPath } from '@/utils/parseSourceCode'; +import Kbd from '@radui/ui/Kbd'; +import Text from '@radui/ui/Text'; + +const example_1_SourceCode = await getSourceCodeFromPath('docs/app/docs/components/popover/docs/examples/popover_example1.tsx'); +const anatomy_SourceCode = await getSourceCodeFromPath('docs/app/docs/components/popover/docs/anatomy.tsx'); + +import root_api_SourceCode from './component_api/root.tsx'; +import trigger_api_SourceCode from './component_api/trigger.tsx'; +import content_api_SourceCode from './component_api/content.tsx'; +import arrow_api_SourceCode from './component_api/arrow.tsx'; + +const code = { + javascript: { + code: example_1_SourceCode + }, + css: { + code: `todo` + } +}; + +export const anatomy = { code: anatomy_SourceCode }; + +const columns = [ + { name: 'Prop', id: 'prop' }, + { name: 'Type', id: 'type' }, + { name: 'Default', id: 'default' }, + { name: 'Description', id: 'description' } +]; + +export const api_documentation = { + root: root_api_SourceCode, + trigger: trigger_api_SourceCode, + content: content_api_SourceCode, + arrow: arrow_api_SourceCode +}; + +export const keyboardShortcuts = { + columns: [ + { name: 'Shortcut', id: 'shortcut' }, + { name: 'Description', id: 'description' } + ], + data: [ + { + shortcut: Escape, + description: Closes the popover., + id: 'escape' + } + ] +}; + +export default code; diff --git a/docs/app/docs/components/popover/docs/component_api/arrow.tsx b/docs/app/docs/components/popover/docs/component_api/arrow.tsx new file mode 100644 index 000000000..0dba502a1 --- /dev/null +++ b/docs/app/docs/components/popover/docs/component_api/arrow.tsx @@ -0,0 +1,12 @@ +const data = { + name: 'Arrow', + description: 'Optional arrow pointing to the trigger.', + columns: [ + { name: 'Prop', id: 'prop' }, + { name: 'Type', id: 'type' }, + { name: 'Default', id: 'default' } + ], + data: [] +}; + +export default data; diff --git a/docs/app/docs/components/popover/docs/component_api/content.tsx b/docs/app/docs/components/popover/docs/component_api/content.tsx new file mode 100644 index 000000000..662d163b7 --- /dev/null +++ b/docs/app/docs/components/popover/docs/component_api/content.tsx @@ -0,0 +1,18 @@ +const data = { + name: 'Content', + description: 'The popup content.', + columns: [ + { name: 'Prop', id: 'prop' }, + { name: 'Type', id: 'type' }, + { name: 'Default', id: 'default' } + ], + data: [ + { + prop: { name: 'portalled', info_tooltips: 'Render inside a portal.' }, + type: 'boolean', + default: 'true' + } + ] +}; + +export default data; diff --git a/docs/app/docs/components/popover/docs/component_api/root.tsx b/docs/app/docs/components/popover/docs/component_api/root.tsx new file mode 100644 index 000000000..f65b48191 --- /dev/null +++ b/docs/app/docs/components/popover/docs/component_api/root.tsx @@ -0,0 +1,33 @@ +const data = { + name: 'Root', + description: 'Provides context for the popover.', + columns: [ + { name: 'Prop', id: 'prop' }, + { name: 'Type', id: 'type' }, + { name: 'Default', id: 'default' } + ], + data: [ + { + prop: { name: 'open', info_tooltips: 'Controlled open state.' }, + type: 'boolean', + default: '--' + }, + { + prop: { name: 'defaultOpen', info_tooltips: 'Whether the popover is open by default.' }, + type: 'boolean', + default: 'false' + }, + { + prop: { name: 'onOpenChange', info_tooltips: 'Callback when open state changes.' }, + type: 'function', + default: '--' + }, + { + prop: { name: 'placement', info_tooltips: 'Preferred placement of the popover.' }, + type: 'Placement', + default: 'bottom' + } + ] +}; + +export default data; diff --git a/docs/app/docs/components/popover/docs/component_api/trigger.tsx b/docs/app/docs/components/popover/docs/component_api/trigger.tsx new file mode 100644 index 000000000..a103b6ab0 --- /dev/null +++ b/docs/app/docs/components/popover/docs/component_api/trigger.tsx @@ -0,0 +1,18 @@ +const data = { + name: 'Trigger', + description: 'Element that toggles the popover.', + columns: [ + { name: 'Prop', id: 'prop' }, + { name: 'Type', id: 'type' }, + { name: 'Default', id: 'default' } + ], + data: [ + { + prop: { name: 'asChild', info_tooltips: 'Render the trigger as child element.' }, + type: 'boolean', + default: 'false' + } + ] +}; + +export default data; diff --git a/docs/app/docs/components/popover/docs/examples/popover_example1.tsx b/docs/app/docs/components/popover/docs/examples/popover_example1.tsx new file mode 100644 index 000000000..1c1cf4c55 --- /dev/null +++ b/docs/app/docs/components/popover/docs/examples/popover_example1.tsx @@ -0,0 +1,23 @@ +"use client"; + +import Card from "@radui/ui/Card"; +import Popover from "@radui/ui/Popover"; +import Text from "@radui/ui/Text"; + +const PopoverExample1 = () => { + return ( + + + + Open Popover + + + + + Hello from the popover! + + + ); +}; + +export default PopoverExample1; diff --git a/docs/app/docs/components/popover/page.mdx b/docs/app/docs/components/popover/page.mdx new file mode 100644 index 000000000..b821e134a --- /dev/null +++ b/docs/app/docs/components/popover/page.mdx @@ -0,0 +1,61 @@ +import PageDetails from '@/components/seo/PageDetails'; +import Documentation from "@/components/layout/Documentation/Documentation"; +import Popover from "@radui/ui/Popover"; +import codeUsage, { anatomy, api_documentation, keyboardShortcuts } from "./docs/codeUsage"; +import PopoverExample1 from "./docs/examples/popover_example1"; +import popoverMetadata from "./seo"; + +export const metadata = popoverMetadata; + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/app/docs/components/popover/seo.ts b/docs/app/docs/components/popover/seo.ts new file mode 100644 index 000000000..5da94726d --- /dev/null +++ b/docs/app/docs/components/popover/seo.ts @@ -0,0 +1,8 @@ +import generateSeoMetadata from "@/utils/seo/generateSeoMetadata"; + +const popoverMetadata = generateSeoMetadata({ + title: 'Popover - Rad UI', + description: 'A headless React Popover component for floating panels triggered by user interaction.' +}); + +export default popoverMetadata; diff --git a/docs/app/docs/docsNavigationSections.tsx b/docs/app/docs/docsNavigationSections.tsx index d034c5f67..5c3f716b6 100644 --- a/docs/app/docs/docsNavigationSections.tsx +++ b/docs/app/docs/docsNavigationSections.tsx @@ -131,6 +131,10 @@ export const docsNavigationSections = [ title:"ToggleGroup", path:"/docs/components/toggle-group" }, + { + title:"Popover", + path:"/docs/components/popover" + }, { title:"Tooltip", path:"/docs/components/tooltip" diff --git a/src/components/ui/Popover/Popover.tsx b/src/components/ui/Popover/Popover.tsx new file mode 100644 index 000000000..19bcb2a0b --- /dev/null +++ b/src/components/ui/Popover/Popover.tsx @@ -0,0 +1,28 @@ +'use client'; + +import React from 'react'; +import PopoverRoot from './fragments/PopoverRoot'; +import PopoverTrigger from './fragments/PopoverTrigger'; +import PopoverContent from './fragments/PopoverContent'; +import PopoverArrow from './fragments/PopoverArrow'; + +interface PopoverComponent extends React.ForwardRefExoticComponent & React.RefAttributes>> { + Root: typeof PopoverRoot; + Trigger: typeof PopoverTrigger; + Content: typeof PopoverContent; + Arrow: typeof PopoverArrow; +} + +const Popover = React.forwardRef, React.ComponentPropsWithoutRef<'div'>>((_, __) => { + console.warn('Direct usage of Popover is not supported. Use Popover.Root etc.'); + return null; +}) as PopoverComponent; + +Popover.displayName = 'Popover'; + +Popover.Root = PopoverRoot; +Popover.Trigger = PopoverTrigger; +Popover.Content = PopoverContent; +Popover.Arrow = PopoverArrow; + +export default Popover; diff --git a/src/components/ui/Popover/context/PopoverContext.tsx b/src/components/ui/Popover/context/PopoverContext.tsx new file mode 100644 index 000000000..fd2a0d216 --- /dev/null +++ b/src/components/ui/Popover/context/PopoverContext.tsx @@ -0,0 +1,14 @@ +import { createContext } from 'react'; + +export type PopoverContextType = { + open: boolean; + setOpen: (open: boolean) => void; + data: any; + interactions: any; + context: any; + arrowRef: React.RefObject; +}; + +const PopoverContext = createContext(null); + +export default PopoverContext; diff --git a/src/components/ui/Popover/fragments/PopoverArrow.tsx b/src/components/ui/Popover/fragments/PopoverArrow.tsx new file mode 100644 index 000000000..1fb97e5b8 --- /dev/null +++ b/src/components/ui/Popover/fragments/PopoverArrow.tsx @@ -0,0 +1,20 @@ +import React, { useContext } from 'react'; +import { FloatingArrow, useMergeRefs } from '@floating-ui/react'; +import PopoverContext from '../context/PopoverContext'; +import clsx from 'clsx'; + +export type PopoverArrowElement = React.ElementRef; +export interface PopoverArrowProps extends Omit, 'context'> {} + +const PopoverArrow = React.forwardRef((props, ref) => { + const popover = useContext(PopoverContext); + if (!popover) { + throw new Error('PopoverArrow must be used within a PopoverRoot component'); + } + const mergedRef = useMergeRefs([popover.arrowRef, ref]); + return ; +}); + +PopoverArrow.displayName = 'PopoverArrow'; + +export default PopoverArrow; diff --git a/src/components/ui/Popover/fragments/PopoverContent.tsx b/src/components/ui/Popover/fragments/PopoverContent.tsx new file mode 100644 index 000000000..b731cba7c --- /dev/null +++ b/src/components/ui/Popover/fragments/PopoverContent.tsx @@ -0,0 +1,72 @@ +import React, { useContext, useRef } from 'react'; +import { FloatingPortal, useMergeRefs } from '@floating-ui/react'; +import PopoverContext from '../context/PopoverContext'; +import Primitive from '~/core/primitives/Primitive'; +import composeEventHandlers from '~/core/hooks/composeEventHandlers'; +import useLayoutEffect from '~/core/hooks/useLayoutEffect'; + +export type PopoverContentElement = React.ElementRef; + +export type PopoverContentProps = React.ComponentPropsWithoutRef & { + children: React.ReactNode; + portalled?: boolean; + container?: HTMLElement | null; +}; + +const PopoverContent = React.forwardRef(({ children, portalled = true, container, style, onKeyDown, ...props }, ref) => { + const popover = useContext(PopoverContext); + if (!popover) { + throw new Error('PopoverContent must be used within a PopoverRoot component'); + } + const { open, data, interactions, context, setOpen } = popover; + const mergedRef = useMergeRefs([context.refs.setFloating, ref]); + const contentRef = useRef(null); + const allRefs = useMergeRefs([mergedRef, contentRef]); + + useLayoutEffect(() => { + if (!open) return; + const root = contentRef.current; + if (!root) return; + const selector = ['button:not([disabled])','[href]','input:not([disabled])','select:not([disabled])','textarea:not([disabled])','[tabindex]:not([tabindex="-1"])'].join(','); + const first = root.querySelector(selector); + if (first) { + first.focus(); + root.removeAttribute('tabindex'); + } else { + root.focus({ preventScroll: true }); + } + }, [open]); + + if (!open) return null; + + const side = data.placement.split('-')[0]; + const align = data.placement.split('-')[1] ?? 'center'; + + const element = ( + { + if (e.key === 'Escape') { + setOpen(false); + e.stopPropagation(); + } + }) + })} + > + {children} + + ); + + return portalled ? {element} : element; +}); + +PopoverContent.displayName = 'PopoverContent'; + +export default PopoverContent; diff --git a/src/components/ui/Popover/fragments/PopoverRoot.tsx b/src/components/ui/Popover/fragments/PopoverRoot.tsx new file mode 100644 index 000000000..04be1f6e6 --- /dev/null +++ b/src/components/ui/Popover/fragments/PopoverRoot.tsx @@ -0,0 +1,70 @@ +'use client'; + +import React, { useRef } from 'react'; +import { useFloating, flip, shift, offset, arrow, autoUpdate, useClick, useDismiss, useRole, useInteractions, Placement } from '@floating-ui/react'; +import useControllableState from '~/core/hooks/useControllableState'; +import useLayoutEffect from '~/core/hooks/useLayoutEffect'; +import PopoverContext from '../context/PopoverContext'; +import { useCreateDataAttribute } from '~/core/hooks/createDataAttribute'; + +const COMPONENT_NAME = 'Popover'; + +export type PopoverRootProps = React.ComponentPropsWithoutRef<'div'> & { + children: React.ReactNode; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + placement?: Placement; +} + +export type PopoverRootElement = React.ElementRef<'div'>; + +const PopoverRoot = React.forwardRef(({ children, open, defaultOpen = false, onOpenChange, placement = 'bottom', ...props }, ref) => { + const arrowRef = useRef(null); + + const [isOpen, setIsOpen] = useControllableState(open, defaultOpen, onOpenChange); + + const data = useFloating({ + placement, + open: isOpen, + onOpenChange: setIsOpen, + middleware: [offset(4), flip(), shift(), arrow({ element: arrowRef })], + whileElementsMounted: autoUpdate + }); + + const context = data.context; + + const click = useClick(context); + const dismiss = useDismiss(context, { escapeKey: true, outsidePress: true }); + const role = useRole(context, { role: 'dialog' }); + + const interactions = useInteractions([click, dismiss, role]); + + const previouslyFocused = useRef(null); + + useLayoutEffect(() => { + if (isOpen) { + previouslyFocused.current = document.activeElement as HTMLElement; + } + }, [isOpen]); + + useLayoutEffect(() => { + if (!isOpen && previouslyFocused.current) { + previouslyFocused.current.focus({ preventScroll: true }); + } + }, [isOpen]); + + const dataAttributes = useCreateDataAttribute('popover', null); + + return ( + +
+ {children} +
+
+ ); +}); + +PopoverRoot.displayName = COMPONENT_NAME + 'Root'; + +export default PopoverRoot; diff --git a/src/components/ui/Popover/fragments/PopoverTrigger.tsx b/src/components/ui/Popover/fragments/PopoverTrigger.tsx new file mode 100644 index 000000000..e02683087 --- /dev/null +++ b/src/components/ui/Popover/fragments/PopoverTrigger.tsx @@ -0,0 +1,36 @@ +import React, { useContext } from 'react'; +import ButtonPrimitive from '~/core/primitives/Button'; +import { useMergeRefs } from '@floating-ui/react'; +import PopoverContext from '../context/PopoverContext'; + +export type PopoverTriggerElement = React.ElementRef; + +export interface PopoverTriggerProps extends React.ComponentPropsWithoutRef { + asChild?: boolean; + children: React.ReactNode; +} + +const PopoverTrigger = React.forwardRef(({ asChild, children, ...props }, ref) => { + const popover = useContext(PopoverContext); + if (!popover) { + throw new Error('PopoverTrigger must be used within a PopoverRoot component'); + } + const { open, interactions, context } = popover; + const { getReferenceProps } = interactions; + const childrenRef = (children as any).ref; + const mergedRef = useMergeRefs([context.refs.setReference, ref, childrenRef]); + return ( + + {children} + + ); +}); + +PopoverTrigger.displayName = 'PopoverTrigger'; + +export default PopoverTrigger; diff --git a/src/components/ui/Popover/stories/Popover.stories.tsx b/src/components/ui/Popover/stories/Popover.stories.tsx new file mode 100644 index 000000000..4566e89f5 --- /dev/null +++ b/src/components/ui/Popover/stories/Popover.stories.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import Popover from '../Popover'; +import SandboxEditor from '~/components/tools/SandboxEditor/SandboxEditor'; + +const meta: Meta = { + title: 'Components/Popover', + component: Popover +}; + +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + render: () => ( + + + + + + + +
Popover content
+
+
+
+ ) +}; diff --git a/src/components/ui/Popover/tests/Popover.behavior.test.tsx b/src/components/ui/Popover/tests/Popover.behavior.test.tsx new file mode 100644 index 000000000..5c7588da8 --- /dev/null +++ b/src/components/ui/Popover/tests/Popover.behavior.test.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithPortal, assertFocusReturn } from '~/test-utils/portal'; +import { axe, keyboard } from "test-utils"; +import Popover from '../Popover'; + +describe('Popover interactions', () => { + test('opens on click and closes on escape with proper data attributes', async () => { + const user = userEvent.setup(); + render( + + Trigger + + +
Content
+
+
+ ); + + const trigger = screen.getByText('Trigger'); + expect(trigger).toHaveAttribute('data-state', 'closed'); + await user.click(trigger); + const content = await screen.findByText("Content"); + const contentWrapper = content.closest("[data-state]") as HTMLElement; + expect(contentWrapper).toHaveAttribute("data-state", "open"); + expect(trigger).toHaveAttribute('data-state', 'open'); + await user.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByText('Content')).toBeNull()); + expect(trigger).toHaveAttribute('data-state', 'closed'); + }); + + test('portal renders content and focus returns to trigger on escape', async () => { + const user = userEvent.setup(); + const { getByText, cleanup } = renderWithPortal( + + Trigger + +
Portalled
+
+
+ ); + const trigger = getByText('Trigger'); + await user.click(trigger); + await screen.findByText('Portalled'); + await user.keyboard('{Escape}'); + await waitFor(() => assertFocusReturn(trigger)); + cleanup(); + }); + + test('has no a11y violations when open', async () => { + const { container } = render( + + Trigger + +
Accessible
+
+
+ ); + const user = keyboard(); + await user.click(screen.getByText('Trigger')); + const results = await axe(container); + expect(results.violations).toHaveLength(0); +}); +}); diff --git a/src/components/ui/Popover/tests/Popover.test.tsx b/src/components/ui/Popover/tests/Popover.test.tsx new file mode 100644 index 000000000..471344e71 --- /dev/null +++ b/src/components/ui/Popover/tests/Popover.test.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import Popover from '../Popover'; + +describe('Popover', () => { + test('renders trigger and toggles content on click', async () => { + render( + + Trigger + +
Content
+
+
+ ); + + const trigger = screen.getByText('Trigger'); + expect(screen.queryByText('Content')).toBeNull(); + await userEvent.click(trigger); + expect(screen.getByText('Content')).toBeInTheDocument(); + await userEvent.click(trigger); + expect(screen.queryByText('Content')).toBeNull(); + }); + + test('forwards refs to subcomponents', async () => { + const rootRef = React.createRef(); + const triggerRef = React.createRef(); + const contentRef = React.createRef(); + + render( + + Trigger + +
Content
+
+
+ ); + + expect(rootRef.current).toBeInstanceOf(HTMLDivElement); + expect(triggerRef.current).toBeInstanceOf(HTMLButtonElement); + expect(contentRef.current).toBeNull(); + await userEvent.click(screen.getByText('Trigger')); + expect(contentRef.current).toBeInstanceOf(HTMLDivElement); + }); +}); diff --git a/styles/themes/components/popover.scss b/styles/themes/components/popover.scss new file mode 100644 index 000000000..09effd38e --- /dev/null +++ b/styles/themes/components/popover.scss @@ -0,0 +1,10 @@ +.rad-ui-popover { + background-color: var(--rad-ui-color-gray-50); + padding: 12px; + box-shadow: 0 10px 38px -10px #0e121659, 0 10px 20px -15px #0e121633; + border-radius: 8px; + + .rad-ui-popover-arrow { + fill: var(--rad-ui-color-gray-50); + } +} diff --git a/styles/themes/default.scss b/styles/themes/default.scss index bddf14cfc..c43caede3 100644 --- a/styles/themes/default.scss +++ b/styles/themes/default.scss @@ -40,6 +40,7 @@ @use "components/navigation-menu"; @use "components/dropdown-menu"; @use "components/number-field"; +@use "components/popover"; @use "components/context-menu"; @use "components/menubar"; @use "components/splitter";