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
2 changes: 1 addition & 1 deletion apps/www/src/content/docs/components/popover/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const getCode = (props: any) => {
return `
<Popover>
<Popover.Trigger asChild>
<Button>Top Popover</Button>
<Button>Popover</Button>
</Popover.Trigger>
<Popover.Content${getPropsString(rest)}>
${children}
Expand Down
7 changes: 0 additions & 7 deletions apps/www/src/content/docs/components/popover/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,3 @@ Control the position and alignment of your popover relative to its trigger.
Customize how the popover aligns with its trigger.

<Demo data={alignDemo} />

## Accessibility

The Callout component includes appropriate ARIA attributes for accessibility:

- Uses semantic HTML elements for proper structure
- Dismiss button includes `aria-label` for screen readers
45 changes: 31 additions & 14 deletions apps/www/src/content/docs/components/popover/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,32 +13,49 @@ export interface PopoverRootProps {
}

export interface PopoverContentProps {
/**
* Accessible label for the popover content.
* @default "Popover content"
*/
ariaLabel?: string;

/** Preferred side of the trigger to render. */
side?: 'top' | 'right' | 'bottom' | 'left';

/** Distance in pixels from the trigger. */
sideOffset?: number;

/** Alignment relative to trigger. */
align?: 'start' | 'center' | 'end';

/** Distance in pixels from the trigger. */
sideOffset?: number;

/** Offset in pixels from alignment edge. */
alignOffset?: number;

/** Boolean to prevent collision with viewport edges. */
avoidCollisions?: boolean;

/** Padding between content and viewport edges. */
collisionPadding?: number;

/** Boundary element for collision detection. */
collisionBoundary?: Element | Element[] | null;

/** Additional CSS class name. */
className?: string;

/** Additional inline styles. */
style?: React.CSSProperties;

/** Custom render function.
*
* @remarks `ReactElement | function`
*/
render?:
| React.ReactElement
| ((props: any, state: any) => React.ReactElement);

/** Element to receive initial focus when popover opens. */
initialFocus?: boolean | number | React.RefObject<HTMLElement>;

/** Element to receive focus when popover closes. */
finalFocus?: boolean | React.RefObject<HTMLElement>;

/** Content to render inside the popover. */
children?: React.ReactNode;
}

export interface PopoverTriggerProps {
/** Boolean to merge props onto child element. */
asChild?: boolean;
/** Additional CSS class name. */
className?: string;
}
51 changes: 25 additions & 26 deletions packages/raystack/components/popover/__tests__/popover.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Popover as PopoverPrimitive } from '@base-ui/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Popover as PopoverPrimitive } from 'radix-ui';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { Button } from '~/components/button';
Expand All @@ -13,9 +13,9 @@ const POPOVER_CONTENT = 'This is popover content';
const BasicPopover = ({
children = <Popover.Content>{POPOVER_CONTENT}</Popover.Content>,
...props
}: PopoverPrimitive.PopoverProps) => (
}: PopoverPrimitive.Root.Props) => (
<Popover {...props}>
<Popover.Trigger asChild>
<Popover.Trigger>
<Button>{TRIGGER_TEXT}</Button>
</Popover.Trigger>
{children}
Expand Down Expand Up @@ -96,13 +96,18 @@ describe('Popover', () => {
</BasicPopover>
);
const content = screen.getByRole('dialog');
expect(content).toHaveAttribute('data-align', align);
// Base UI uses data-align on the positioner, not the popup
const positioner = content.closest('[data-align]');
expect(positioner).toHaveAttribute('data-align', align);
});
it('applies default align to center', async () => {
await renderAndOpenPopover(<BasicPopover />);

const dialog = screen.getByRole('dialog');
expect(dialog).toHaveAttribute('data-align', 'center');
await waitFor(() => {
const dialog = screen.getByRole('dialog');
const positioner = dialog.closest('[data-align]');
expect(positioner).toHaveAttribute('data-align', 'center');
});
});

const sideValues = ['top', 'right', 'bottom', 'left'] as const;
Expand All @@ -113,13 +118,18 @@ describe('Popover', () => {
</BasicPopover>
);
const content = screen.getByRole('dialog');
expect(content).toHaveAttribute('data-side', side);
// Base UI uses data-side on the positioner, not the popup
const positioner = content.closest('[data-side]');
expect(positioner).toHaveAttribute('data-side', side);
});

it('applies default side to bottom', async () => {
await renderAndOpenPopover(<BasicPopover />);
const dialog = screen.getByRole('dialog');
expect(dialog).toHaveAttribute('data-side', 'bottom');
await waitFor(() => {
const dialog = screen.getByRole('dialog');
const positioner = dialog.closest('[data-side]');
expect(positioner).toHaveAttribute('data-side', 'bottom');
});
});
});

Expand Down Expand Up @@ -180,7 +190,10 @@ describe('Popover', () => {
const trigger = screen.getByText(TRIGGER_TEXT);
await user.click(trigger);

expect(onOpenChange).toHaveBeenCalledWith(true);
expect(onOpenChange).toHaveBeenCalled();
// Base UI passes the open state as the first argument
const callArgs = onOpenChange.mock.calls[0];
expect(callArgs[0]).toBe(true);
});
});

Expand All @@ -191,32 +204,18 @@ describe('Popover', () => {
await waitFor(() => {
const dialog = screen.getByRole('dialog');
expect(dialog).toBeInTheDocument();
expect(dialog).toHaveAttribute('aria-modal', 'true');
});
});

it('has default ARIA label', async () => {
it('has proper ARIA attributes', async () => {
await renderAndOpenPopover(<BasicPopover />);

await waitFor(() => {
const dialog = screen.getByRole('dialog');
expect(dialog).toHaveAttribute('aria-label', 'Popover content');
expect(dialog).toBeInTheDocument();
});
});

it('uses custom ARIA label when provided', async () => {
await renderAndOpenPopover(
<BasicPopover>
<Popover.Content ariaLabel='Custom popover label'>
{POPOVER_CONTENT}
</Popover.Content>
</BasicPopover>
);

const dialog = screen.getByRole('dialog');
expect(dialog).toHaveAttribute('aria-label', 'Custom popover label');
});

it('has proper focus management', async () => {
await renderAndOpenPopover(
<BasicPopover>
Expand Down
6 changes: 3 additions & 3 deletions packages/raystack/components/popover/popover.module.css
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
.popover {
.popoverPositioner {
z-index: var(--rs-z-index-portal);
}
.popover {
outline: 0;
overflow: hidden;
font-size: var(--rs-font-size-small);
line-height: var(--rs-line-height-small);
letter-spacing: var(--rs-letter-spacing-small);

box-sizing: border-box;
min-width: 120px;
max-width: 18rem;

padding: var(--rs-space-3);
background-color: var(--rs-color-background-base-primary);
border-radius: var(--rs-radius-2);
Expand Down
75 changes: 40 additions & 35 deletions packages/raystack/components/popover/popover.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,57 @@
'use client';

import { cva } from 'class-variance-authority';
import { Popover as PopoverPrimitive } from 'radix-ui';
import React from 'react';

import { Popover as PopoverPrimitive } from '@base-ui/react';
import { cx } from 'class-variance-authority';
import { ElementRef, forwardRef } from 'react';
import styles from './popover.module.css';

const popoverContent = cva(styles.popover);

export interface PopoverContentProps
extends React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> {
ariaLabel?: string;
}
extends Omit<
PopoverPrimitive.Positioner.Props,
'render' | 'className' | 'style'
>,
PopoverPrimitive.Popup.Props {}

const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
const PopoverContent = forwardRef<
ElementRef<typeof PopoverPrimitive.Popup>,
PopoverContentProps
>(
(
{
initialFocus,
finalFocus,
className,
align = 'center',
sideOffset = 4,
ariaLabel = 'Popover content',
collisionPadding = 3,
...props
style,
render,
children,
...positionerProps
},
ref
) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
collisionPadding={collisionPadding}
avoidCollisions
className={popoverContent({ className })}
role='dialog'
aria-modal='true'
aria-label={ariaLabel}
{...props}
>
{props.children}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
)
) => {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
sideOffset={4}
collisionPadding={3}
className={styles.popoverPositioner}
{...positionerProps}
>
<PopoverPrimitive.Popup
ref={ref}
className={cx(styles.popover, className)}
render={render}
initialFocus={initialFocus}
finalFocus={finalFocus}
style={style}
>
{children}
</PopoverPrimitive.Popup>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
);
}
);
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
PopoverContent.displayName = 'Popover.Content';

export const Popover = Object.assign(PopoverPrimitive.Root, {
Trigger: PopoverPrimitive.Trigger,
Expand Down