Skip to content

Commit

Permalink
feat: Add UI Primitives - FocusTrap, DismissableLayer, and Dialog.
Browse files Browse the repository at this point in the history
  • Loading branch information
cpcramer committed Jan 17, 2025
1 parent 15ce2a0 commit c0b1e02
Show file tree
Hide file tree
Showing 10 changed files with 729 additions and 261 deletions.
5 changes: 5 additions & 0 deletions .changeset/dull-shirts-confess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@coinbase/onchainkit": patch
---

- **feat**: Add UI Primitives - FocusTrap, DismissableLayer, and Dialog. By @cpcramer #1822
139 changes: 139 additions & 0 deletions src/internal/primitives/Dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Dialog } from './Dialog';

vi.mock('react-dom', () => ({
createPortal: (node: React.ReactNode) => node,
}));

describe('Dialog', () => {
const onClose = vi.fn();

beforeEach(() => {
onClose.mockClear();

Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});

it('renders nothing when isOpen is false', () => {
render(
<Dialog isOpen={false} onClose={onClose}>
<div>Dialog content</div>
</Dialog>,
);

expect(screen.queryByTestId('ockDialog')).not.toBeInTheDocument();
});

it('renders content when isOpen is true', () => {
render(
<Dialog isOpen={true} onClose={onClose}>
<div data-testid="content">Dialog content</div>
</Dialog>,
);

expect(screen.getByTestId('ockDialog')).toBeInTheDocument();
expect(screen.getByTestId('content')).toBeInTheDocument();
});

it('sets correct ARIA attributes', () => {
render(
<Dialog
isOpen={true}
ariaLabel="Test Dialog"
ariaDescribedby="dialog-desc"
ariaLabelledby="dialog-title"
onClose={() => {}}
>
<div>Dialog content</div>
</Dialog>,
);

const dialog = screen.getByTestId('ockDialog');
expect(dialog).toHaveAttribute('aria-label', 'Test Dialog');
expect(dialog).toHaveAttribute('aria-describedby', 'dialog-desc');
expect(dialog).toHaveAttribute('aria-labelledby', 'dialog-title');
});

it('sets modal attribute correctly', () => {
render(
<Dialog isOpen={true} modal={false}>
<div>Dialog content</div>
</Dialog>,
);

expect(screen.getByTestId('ockDialog')).toHaveAttribute(
'aria-modal',
'false',
);
});

it('stops event propagation on dialog click', () => {
render(
<Dialog isOpen={true} onClose={onClose}>
<div>Dialog content</div>
</Dialog>,
);

const dialog = screen.getByTestId('ockDialog');
const clickEvent = new MouseEvent('click', { bubbles: true });
const stopPropagationSpy = vi.spyOn(clickEvent, 'stopPropagation');

fireEvent(dialog, clickEvent);
expect(stopPropagationSpy).toHaveBeenCalled();
});

it('stops propagation of Enter and Space key events', () => {
render(
<Dialog isOpen={true} onClose={onClose}>
<div>Dialog content</div>
</Dialog>,
);

const dialog = screen.getByTestId('ockDialog');

const enterEvent = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
});
const spaceEvent = new KeyboardEvent('keydown', {
key: ' ',
bubbles: true,
});

const enterSpy = vi.spyOn(enterEvent, 'stopPropagation');
const spaceSpy = vi.spyOn(spaceEvent, 'stopPropagation');

fireEvent(dialog, enterEvent);
fireEvent(dialog, spaceEvent);

expect(enterSpy).toHaveBeenCalled();
expect(spaceSpy).toHaveBeenCalled();
});

it('calls onClose when clicking outside or pressing Escape', () => {
render(
<Dialog isOpen={true} onClose={onClose}>
<div>Dialog content</div>
</Dialog>,
);

fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);

fireEvent.pointerDown(document.body);
expect(onClose).toHaveBeenCalledTimes(2);
});
});
77 changes: 77 additions & 0 deletions src/internal/primitives/Dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type React from 'react';
import { useRef } from 'react';
import { createPortal } from 'react-dom';
import { useTheme } from '../../core-react/internal/hooks/useTheme';
import { cn } from '../../styles/theme';
import { DismissableLayer } from './DismissableLayer';
import { FocusTrap } from './FocusTrap';

type DialogProps = {
children?: React.ReactNode;
isOpen?: boolean;
onClose?: () => void;
modal?: boolean;
ariaLabel?: string;
ariaLabelledby?: string;
ariaDescribedby?: string;
};

/**
* Dialog primitive that handles:
* Portaling to document.body
* Focus management (trapping focus within dialog)
* Click outside and escape key dismissal
* Proper ARIA attributes for accessibility
*/
export function Dialog({
children,
isOpen,
modal = true,
onClose,
ariaLabel,
ariaLabelledby,
ariaDescribedby,
}: DialogProps) {
const componentTheme = useTheme();
const dialogRef = useRef<HTMLDivElement>(null);

if (!isOpen) {
return null;
}

const dialog = (
<div
className={cn(
componentTheme,
'fixed inset-0 z-50 flex items-center justify-center',
'bg-black/50 transition-opacity duration-200',
'fade-in animate-in duration-200',
)}
>
<FocusTrap active={isOpen}>
<DismissableLayer onDismiss={onClose}>
<div
aria-modal={modal}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-describedby={ariaDescribedby}
data-testid="ockDialog"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
}
}}
ref={dialogRef}
role="dialog"
className="zoom-in-95 animate-in duration-200"
>
{children}
</div>
</DismissableLayer>
</FocusTrap>
</div>
);

return createPortal(dialog, document.body);
}
109 changes: 109 additions & 0 deletions src/internal/primitives/DismissableLayer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DismissableLayer } from './DismissableLayer';

describe('DismissableLayer', () => {
const onDismiss = vi.fn();

beforeEach(() => {
onDismiss.mockClear();
});

afterEach(() => {
vi.clearAllMocks();
});

it('renders children correctly', () => {
render(
<DismissableLayer>
<div data-testid="child">Test Content</div>
</DismissableLayer>,
);

expect(screen.getByTestId('child')).toBeInTheDocument();
});

it('calls onDismiss when Escape key is pressed', () => {
render(
<DismissableLayer onDismiss={onDismiss}>
<div>Test Content</div>
</DismissableLayer>,
);

fireEvent.keyDown(document, { key: 'Escape' });
expect(onDismiss).toHaveBeenCalledTimes(1);
});

it('does not call onDismiss when Escape key is pressed and disableEscapeKey is true', () => {
render(
<DismissableLayer onDismiss={onDismiss} disableEscapeKey={true}>
<div>Test Content</div>
</DismissableLayer>,
);

fireEvent.keyDown(document, { key: 'Escape' });
expect(onDismiss).not.toHaveBeenCalled();
});

it('calls onDismiss when clicking outside', () => {
render(
<DismissableLayer onDismiss={onDismiss}>
<div>Test Content</div>
</DismissableLayer>,
);

fireEvent.pointerDown(document.body);
expect(onDismiss).toHaveBeenCalledTimes(1);
});

it('does not call onDismiss when clicking inside', () => {
render(
<DismissableLayer onDismiss={onDismiss}>
<div data-testid="inner">Test Content</div>
</DismissableLayer>,
);

const innerElement = screen.getByTestId('inner');
fireEvent.pointerDown(innerElement);
expect(onDismiss).not.toHaveBeenCalled();
});

it('does not call onDismiss when clicking outside and disableOutsideClick is true', () => {
render(
<DismissableLayer onDismiss={onDismiss} disableOutsideClick={true}>
<div>Test Content</div>
</DismissableLayer>,
);

fireEvent.pointerDown(document.body);
expect(onDismiss).not.toHaveBeenCalled();
});

it('handles case when both disableEscapeKey and disableOutsideClick are true', () => {
render(
<DismissableLayer
onDismiss={onDismiss}
disableEscapeKey={true}
disableOutsideClick={true}
>
<div>Test Content</div>
</DismissableLayer>,
);

fireEvent.keyDown(document, { key: 'Escape' });
fireEvent.pointerDown(document.body);

expect(onDismiss).not.toHaveBeenCalled();
});

it('handles undefined onDismiss prop gracefully', () => {
render(
<DismissableLayer>
<div>Test Content</div>
</DismissableLayer>,
);

fireEvent.keyDown(document, { key: 'Escape' });
fireEvent.pointerDown(document.body);
});
});
Loading

0 comments on commit c0b1e02

Please sign in to comment.