Skip to content
Merged
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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,23 @@ function App() {
/>
```

### Modal

`className`, `panelClassName`, and `style` target the modal panel. `overlayClassName` targets the backdrop.

```tsx
<Modal
open={open}
onClose={onClose}
size="sm"
panelClassName="batch-run-panel"
style={{ maxWidth: 520 }}
>
<ModalHeader title="Run batch action?" onClose={onClose} />
<ModalBody>Review the selected documents before continuing.</ModalBody>
</Modal>
```

### FilterTabs

```tsx
Expand Down
81 changes: 81 additions & 0 deletions src/Modal/Modal.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ const meta: Meta<typeof Modal> = {
closeOnEscape: {
control: 'boolean',
},
panelClassName: {
control: 'text',
},
overlayClassName: {
control: 'text',
},
},
};

Expand Down Expand Up @@ -241,6 +247,81 @@ export const WithOverlayClassName: Story = {
},
};

export const WithCustomPanelWidth: Story = {
render: () => {
const [open, setOpen] = useState(false);

return (
<>
<style>{`.batch-run-panel { border-top: 4px solid #E85A4F; }`}</style>
<button
onClick={() => setOpen(true)}
style={{
padding: '10px 20px',
background: '#E85A4F',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontWeight: 500,
}}
>
Open Modal with Custom Panel
</button>

<Modal
open={open}
onClose={() => setOpen(false)}
size="sm"
panelClassName="batch-run-panel"
style={{ maxWidth: 520 }}
>
<ModalHeader
title="Batch Run Actions"
subtitle="Custom panel sizing through a public panel class"
onClose={() => setOpen(false)}
/>
<ModalBody>
<p style={{ margin: 0 }}>
This modal uses <code>panelClassName</code> to apply a 520px max-width
without targeting the internal <code>.oc-modal</code> selector.
</p>
</ModalBody>
<ModalFooter>
<button
onClick={() => setOpen(false)}
style={{
padding: '8px 16px',
background: 'transparent',
color: '#1A1A1A',
border: '1px solid #E5E5E5',
borderRadius: '8px',
cursor: 'pointer',
}}
>
Cancel
</button>
<button
onClick={() => setOpen(false)}
style={{
padding: '8px 16px',
background: '#E85A4F',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontWeight: 500,
}}
>
Run
</button>
</ModalFooter>
</Modal>
</>
);
},
};

export const Confirmation: Story = {
render: () => {
const [open, setOpen] = useState(false);
Expand Down
52 changes: 52 additions & 0 deletions src/Modal/Modal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// @vitest-environment jsdom
import React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { Modal, ModalBody } from './Modal';

afterEach(() => {
cleanup();
});

function renderOpenModal(props: Partial<React.ComponentProps<typeof Modal>> = {}) {
const onClose = vi.fn();

render(
<Modal open onClose={onClose} {...props}>
<ModalBody>Modal content</ModalBody>
</Modal>
);

return { onClose };
}

describe('Modal panel API', () => {
it('applies className, panelClassName, and style to the dialog panel', () => {
renderOpenModal({
className: 'consumer-panel',
panelClassName: 'batch-run-panel',
style: { maxWidth: 520 },
});

const panel = screen.getByRole('dialog');

expect(panel.classList.contains('oc-modal')).toBe(true);
expect(panel.classList.contains('consumer-panel')).toBe(true);
expect(panel.classList.contains('batch-run-panel')).toBe(true);
expect((panel as HTMLElement).style.maxWidth).toBe('520px');
});

it('keeps overlayClassName scoped to the backdrop overlay', () => {
renderOpenModal({
panelClassName: 'consumer-panel',
overlayClassName: 'consumer-overlay',
});

const panel = screen.getByRole('dialog');
const overlay = panel.parentElement;

expect(panel.classList.contains('consumer-overlay')).toBe(false);
expect(overlay?.classList.contains('consumer-overlay')).toBe(true);
expect(overlay?.classList.contains('consumer-panel')).toBe(false);
});
});
11 changes: 10 additions & 1 deletion src/Modal/Modal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { forwardRef, ReactNode, HTMLAttributes, useEffect, useCallback } from 'react';
import React, { forwardRef, ReactNode, HTMLAttributes, CSSProperties, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';

export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full' | 'fullscreen';
Expand All @@ -9,6 +9,13 @@ export interface ModalProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'
size?: ModalSize;
closeOnOverlay?: boolean;
closeOnEscape?: boolean;
/** Applies to the modal panel. Use this for panel styling without targeting .oc-modal. */
className?: string;
/** Applies to the modal panel. Supports arbitrary panel sizing such as maxWidth. */
style?: CSSProperties;
/** Additional class name applied to the modal panel. */
panelClassName?: string;
/** Class name applied to the backdrop overlay. */
overlayClassName?: string;
children?: ReactNode;
}
Expand All @@ -35,6 +42,7 @@ export const Modal = forwardRef<HTMLDivElement, ModalProps>(
closeOnEscape = true,
overlayClassName = '',
className = '',
panelClassName = '',
children,
...props
},
Expand Down Expand Up @@ -72,6 +80,7 @@ export const Modal = forwardRef<HTMLDivElement, ModalProps>(
'oc-modal',
`oc-modal--${size}`,
className,
panelClassName,
].filter(Boolean).join(' ');

const overlayClasses = [
Expand Down
Loading