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
5 changes: 5 additions & 0 deletions .changeset/little-readers-drum.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@radix-ui/react-toast': patch
---

Allow to specify container for ToastAnnounce
43 changes: 43 additions & 0 deletions apps/storybook/stories/toast.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,49 @@ export const Cypress = () => {
);
};

export const CustomAnnouncerContainer = () => {
const [open, setOpen] = React.useState(false);
const containerRef = React.useRef<HTMLDivElement>(null);

return (
<div>
<div
ref={containerRef}
data-testid="custom-announcer-container"
style={{
border: '2px dashed #ccc',
padding: '10px',
margin: '10px 0',
minHeight: '50px',
}}
>
<p>Custom announcer container (announcements will be rendered here)</p>
</div>

<Toast.Provider announcerContainer={containerRef.current || undefined}>
<button onClick={() => setOpen(true)} data-testid="open-toast-button">
Open toast with custom announcer container
</button>

<Toast.Root
open={open}
onOpenChange={setOpen}
className={styles.root}
data-testid="custom-container-toast"
>
<Toast.Title className={styles.title}>Custom Container Toast</Toast.Title>
<Toast.Description className={styles.description}>
This toast's announcements are rendered in a custom container
</Toast.Description>
<Toast.Close className={styles.button}>Close</Toast.Close>
</Toast.Root>

<Toast.Viewport className={styles.viewport} />
</Toast.Provider>
</div>
);
};

const SNAPSHOT_DELAY = 300;

export const Chromatic = () => {
Expand Down
73 changes: 73 additions & 0 deletions cypress/e2e/Toast.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,5 +95,78 @@ describe('Toast', () => {
cy.realPress(['Shift', 'Tab']);
cy.findByText('Focusable before viewport').should('be.focused');
});

it('should render announcements in document body by default', () => {
// Add a toast to trigger announcement
cy.findByText('Add toast').click();

// Verify announcement is rendered in document body (default behavior)
cy.get('body').within(() => {
cy.get('[role="status"]')
.should('exist')
.and('contain.text', 'Notification')
.and('contain.text', 'Toast 1 title')
.and('contain.text', 'Toast 1 description');
});

// Wait for announcement cleanup
cy.wait(1100);

// Verify announcement is cleaned up
cy.get('body [role="status"]').should('not.exist');
});
});

describe('given custom announcer container', () => {
beforeEach(() => {
cy.visitStory('toast--custom-announcer-container');
});

it('should render announcements in the custom container', () => {
// Initially, no announcements should be present
cy.findByTestId('custom-announcer-container')
.should('exist')
.within(() => {
cy.get('[role="status"]').should('not.exist');
});

// Open a toast
cy.findByTestId('open-toast-button').click();

// Verify the toast is visible
cy.findByTestId('custom-container-toast').should('be.visible');

// Verify the announcement is rendered in the custom container
cy.findByTestId('custom-announcer-container').within(() => {
cy.get('[role="status"]')
.should('exist')
.and('contain.text', 'Notification')
.and('contain.text', 'Custom Container Toast')
.and('contain.text', 'This toast\'s announcements are rendered in a custom container');
});

// Verify the announcement is NOT directly in the document body (default behavior)
cy.get('body > [role="status"]').should('not.exist');
});

it('should clean up announcements after timeout', () => {
// Open a toast
cy.findByTestId('open-toast-button').click();

// Verify announcement exists initially
cy.findByTestId('custom-announcer-container').within(() => {
cy.get('[role="status"]').should('exist');
});

// Wait for announcement cleanup (1000ms timeout)
cy.wait(1100);

// Verify announcement is cleaned up
cy.findByTestId('custom-announcer-container').within(() => {
cy.get('[role="status"]').should('not.exist');
});
});

});

});
12 changes: 11 additions & 1 deletion packages/react/toast/src/toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type ToastProviderContextValue = {
onToastRemove(): void;
isFocusedToastEscapeKeyDownRef: React.MutableRefObject<boolean>;
isClosePausedRef: React.MutableRefObject<boolean>;
announcerContainer?: Element | DocumentFragment;
};

type ScopedProps<P> = P & { __scopeToast?: Scope };
Expand Down Expand Up @@ -66,6 +67,13 @@ interface ToastProviderProps {
* @defaultValue 50
*/
swipeThreshold?: number;
/**
* An optional container where the toast announcements should be appended.
* This is useful when working with focus traps or modal dialogs that make
* other elements inert.
* @defaultValue document.body
*/
announcerContainer?: Element | DocumentFragment;
}

const ToastProvider: React.FC<ToastProviderProps> = (props: ScopedProps<ToastProviderProps>) => {
Expand All @@ -75,6 +83,7 @@ const ToastProvider: React.FC<ToastProviderProps> = (props: ScopedProps<ToastPro
duration = 5000,
swipeDirection = 'right',
swipeThreshold = 50,
announcerContainer,
children,
} = props;
const [viewport, setViewport] = React.useState<ToastViewportElement | null>(null);
Expand Down Expand Up @@ -103,6 +112,7 @@ const ToastProvider: React.FC<ToastProviderProps> = (props: ScopedProps<ToastPro
onToastRemove={React.useCallback(() => setToastCount((prevCount) => prevCount - 1), [])}
isFocusedToastEscapeKeyDownRef={isFocusedToastEscapeKeyDownRef}
isClosePausedRef={isClosePausedRef}
announcerContainer={announcerContainer}
>
{children}
</ToastProviderProvider>
Expand Down Expand Up @@ -686,7 +696,7 @@ const ToastAnnounce: React.FC<ToastAnnounceProps> = (props: ScopedProps<ToastAnn
}, []);

return isAnnounced ? null : (
<Portal asChild>
<Portal asChild container={context.announcerContainer || undefined}>
<VisuallyHidden {...announceProps}>
{renderAnnounceText && (
<>
Expand Down