Skip to content

Commit

Permalink
Use composition instead to split ToastContainer into BodyPortal and n…
Browse files Browse the repository at this point in the history
…ormal versions
  • Loading branch information
Dantemss committed Jan 24, 2025
1 parent d242516 commit 432d464
Show file tree
Hide file tree
Showing 6 changed files with 214 additions and 30 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openstax/ui-components",
"version": "1.11.2",
"version": "1.12.0",
"license": "MIT",
"source": "./src/index.ts",
"types": "./dist/index.d.ts",
Expand Down
19 changes: 7 additions & 12 deletions src/components/BodyPortal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,25 +23,20 @@ const getInsertBeforeTarget = (bodyPortalSlots: string[], slot?: string) => {

export type BodyPortalProps = React.PropsWithChildren<{
className?: string;
portal?: boolean;
role?: string;
slot?: string;
// List only elements that can have children
tagName?: 'div' | 'span' | 'nav' | 'main' | 'header' | 'footer';
tagName?: string;
id?: string;
'data-testid'?: string;
}>;

export const BodyPortal = React.forwardRef<HTMLElement, BodyPortalProps>((
{ children, className, portal = true, role, slot, tagName, id, ...props }, ref?: React.ForwardedRef<HTMLElement>
{ children, className, role, slot, tagName, id, ...props }, ref?: React.ForwardedRef<HTMLElement>
) => {
const TagName = tagName ?? 'div';
if (!portal) { return <TagName className={className} id={id} role={role} {...props}>{children}</TagName>; }

const upperTagName = TagName.toUpperCase();
const internalRef = React.useRef<HTMLElement>(document.createElement(upperTagName));
if (internalRef.current.tagName !== upperTagName) {
internalRef.current = document.createElement(upperTagName);
const tag = tagName?.toUpperCase() ?? 'DIV';
const internalRef = React.useRef<HTMLElement>(document.createElement(tag));
if (internalRef.current.tagName !== tag) {
internalRef.current = document.createElement(tag);
}
if (ref) {
if (typeof ref === 'function') {
Expand Down Expand Up @@ -84,7 +79,7 @@ export const BodyPortal = React.forwardRef<HTMLElement, BodyPortalProps>((

if (testId) { delete element.dataset.testid; }
};
}, [bodyPortalOrderedRefs, className, id, role, slot, upperTagName, testId]);
}, [bodyPortalOrderedRefs, className, id, role, slot, tag, testId]);

return createPortal(children, internalRef.current);
});
34 changes: 28 additions & 6 deletions src/components/ToastContainer.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { render, act } from '@testing-library/react';
import { ToastContainer } from './ToastContainer';
import { act, render } from '@testing-library/react';
import { BodyPortalToastContainer, ToastContainer } from './ToastContainer';
import { ToastData } from '../../src/types';

jest.useFakeTimers();
Expand All @@ -10,7 +10,7 @@ const toasts: ToastData[] = [
{ id: '3', title: 'Success', message: 'message', variant: 'success', dismissAfterMs: 2000 },
];

describe('ToastContainer', () => {
describe('BodyPortalToastContainer', () => {
let root: HTMLElement;

beforeEach(() => {
Expand All @@ -20,18 +20,40 @@ describe('ToastContainer', () => {
});

it('matches snapshot', () => {
render(<ToastContainer toasts={toasts} />, { container: root });
render(<BodyPortalToastContainer toasts={toasts} />, { container: root });
expect(document.body).toMatchSnapshot();
});

it('uses inline prop', () => {
render(<BodyPortalToastContainer toasts={toasts} inline={true} />, { container: root });
expect(document.body).toMatchSnapshot();
});

it('runs callback', () => {
const callback = jest.fn();
render(<BodyPortalToastContainer toasts={toasts} onDismissToast={callback} />, { container: root });
act(() => {
jest.advanceTimersByTime(10000);
expect(callback).toHaveBeenCalledWith('3');
expect(callback).toHaveBeenCalledWith('2');
});
});
});

describe('ToastContainer', () => {
it('matches snapshot', () => {
render(<ToastContainer toasts={toasts} />);
expect(document.body).toMatchSnapshot();
});

it('uses inline prop', () => {
render(<ToastContainer toasts={toasts} inline={true} />, { container: root });
render(<ToastContainer toasts={toasts} inline={true} />);
expect(document.body).toMatchSnapshot();
});

it('runs callback', () => {
const callback = jest.fn();
render(<ToastContainer toasts={toasts.splice(0)} onDismissToast={callback} />, { container: root });
render(<ToastContainer toasts={toasts} onDismissToast={callback} />);
act(() => {
jest.advanceTimersByTime(10000);
expect(callback).toHaveBeenCalledWith('3');
Expand Down
12 changes: 10 additions & 2 deletions src/components/ToastContainer.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import styled from 'styled-components';
import { ToastData } from '../../src/types';
import { ToastContainer } from './ToastContainer';
import { BodyPortalToastContainer, ToastContainer } from './ToastContainer';

const StyledBodyPortalToastContainer = styled(BodyPortalToastContainer)`
top: 2rem;
left: 2rem;
right: unset;
`;

const StyledToastContainer = styled(ToastContainer)`
top: 2rem;
Expand All @@ -13,4 +19,6 @@ const toasts: ToastData[] = [
{ title: 'Error', message: 'message', variant: 'failure', dismissAfterMs: 4000 },
{ title: 'Success', message: 'message', variant: 'success', dismissAfterMs: 2000 },
];
export const Default = () => <StyledToastContainer toasts={toasts} />;

export const UsingBodyPortal = () => <StyledBodyPortalToastContainer toasts={toasts} />;
export const WithoutBodyPortal = () => <StyledToastContainer toasts={toasts} />;
35 changes: 30 additions & 5 deletions src/components/ToastContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Toast } from './Toast';
import { zIndex } from '../../src/theme';
import { ToastData } from '../../src/types';

const StyledToastContainer = styled(BodyPortal)`
const StyledToastContainer = styled.div`
${(props: {inline: boolean}) => !props.inline && css`
position: fixed;
right: 2rem;
Expand All @@ -16,15 +16,40 @@ const StyledToastContainer = styled(BodyPortal)`
gap: 1vh;
`;

export const ToastContainer = ({ toasts, onDismissToast, inline = false, portal = true, className }: {
toasts: ToastData[], onDismissToast?: ToastData['onDismiss'], inline?: boolean, portal?: boolean; className?: string
const StyledBodyPortalToastContainer = styled(BodyPortal)`
${(props: {inline: boolean}) => !props.inline && css`
position: fixed;
right: 2rem;
`}
z-index: ${zIndex.toasts};
display: grid;
justify-items: center;
justify-content: center;
gap: 1vh;
`;

export const ToastContainer = ({ toasts, onDismissToast, inline = false, className }: {
toasts: ToastData[], onDismissToast?: ToastData['onDismiss'], inline?: boolean, className?: string
}) => {
return <StyledToastContainer inline={inline} aria-live="polite" portal={portal} slot='toast' className={className}>
return <StyledToastContainer inline={inline} aria-live="polite" slot='toast' className={className}>
{toasts.map((toast, index) => <Toast
key={`toast-${index}`}
onDismiss={onDismissToast}
inline={inline}
{...toast}
>{toast.message}</Toast>)}
</StyledToastContainer>
}
};

export const BodyPortalToastContainer = ({ toasts, onDismissToast, inline = false, className }: {
toasts: ToastData[], onDismissToast?: ToastData['onDismiss'], inline?: boolean, className?: string
}) => {
return <StyledBodyPortalToastContainer inline={inline} aria-live="polite" slot='toast' className={className}>
{toasts.map((toast, index) => <Toast
key={`toast-${index}`}
onDismiss={onDismissToast}
inline={inline}
{...toast}
>{toast.message}</Toast>)}
</StyledBodyPortalToastContainer>
};
142 changes: 138 additions & 4 deletions src/components/__snapshots__/ToastContainer.spec.tsx.snap
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`ToastContainer matches snapshot 1`] = `
exports[`BodyPortalToastContainer matches snapshot 1`] = `
<body>
<main
id="root"
/>
<div
class="sc-gsnTZi cA-dmZH"
class="sc-dkzDqf cDpjbc"
data-portal-slot="toast"
>
<div
Expand Down Expand Up @@ -67,13 +67,13 @@ exports[`ToastContainer matches snapshot 1`] = `
</body>
`;

exports[`ToastContainer uses inline prop 1`] = `
exports[`BodyPortalToastContainer uses inline prop 1`] = `
<body>
<main
id="root"
/>
<div
class="sc-gsnTZi ejWjjm"
class="sc-dkzDqf iRjmjx"
data-portal-slot="toast"
>
<div
Expand Down Expand Up @@ -133,3 +133,137 @@ exports[`ToastContainer uses inline prop 1`] = `
</div>
</body>
`;

exports[`ToastContainer matches snapshot 1`] = `
<body>
<div>
<div
aria-live="polite"
class="sc-gsnTZi cA-dmZH"
slot="toast"
>
<div
class="sc-bczRLJ jAZNhq"
>
<div
class="neutral"
>
<div
class="title"
>
Neutral
</div>
<div
class="body"
>
message
</div>
</div>
</div>
<div
class="sc-bczRLJ cWrNHg"
>
<div
class="failure"
>
<div
class="title"
>
Error
</div>
<div
class="body"
>
message
</div>
</div>
</div>
<div
class="sc-bczRLJ loazPe"
>
<div
class="success"
>
<div
class="title"
>
Success
</div>
<div
class="body"
>
message
</div>
</div>
</div>
</div>
</div>
</body>
`;

exports[`ToastContainer uses inline prop 1`] = `
<body>
<div>
<div
aria-live="polite"
class="sc-gsnTZi ejWjjm"
slot="toast"
>
<div
class="sc-bczRLJ jSNerL"
>
<div
class="neutral"
>
<div
class="title"
>
Neutral
</div>
<div
class="body"
>
message
</div>
</div>
</div>
<div
class="sc-bczRLJ fgCwHl"
>
<div
class="failure"
>
<div
class="title"
>
Error
</div>
<div
class="body"
>
message
</div>
</div>
</div>
<div
class="sc-bczRLJ dGEkoL"
>
<div
class="success"
>
<div
class="title"
>
Success
</div>
<div
class="body"
>
message
</div>
</div>
</div>
</div>
</div>
</body>
`;

0 comments on commit 432d464

Please sign in to comment.