-
Notifications
You must be signed in to change notification settings - Fork 214
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add UI Primitives - FocusTrap, DismissableLayer, and Dialog.
- Loading branch information
Showing
10 changed files
with
729 additions
and
261 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
Oops, something went wrong.