From 1c3374b8625f726d32d9d3362fc5baf33041cebe Mon Sep 17 00:00:00 2001 From: Ihor Tsvietkov Date: Tue, 26 Aug 2025 13:37:57 +0300 Subject: [PATCH 1/2] Toast: Allow to specify container for ToastAnnounce --- apps/storybook/stories/toast.stories.tsx | 43 ++++++++++++++ cypress/e2e/Toast.cy.ts | 73 ++++++++++++++++++++++++ packages/react/toast/src/toast.tsx | 12 +++- 3 files changed, 127 insertions(+), 1 deletion(-) diff --git a/apps/storybook/stories/toast.stories.tsx b/apps/storybook/stories/toast.stories.tsx index 499d4ace6..4b3ef81cf 100644 --- a/apps/storybook/stories/toast.stories.tsx +++ b/apps/storybook/stories/toast.stories.tsx @@ -263,6 +263,49 @@ export const Cypress = () => { ); }; +export const CustomAnnouncerContainer = () => { + const [open, setOpen] = React.useState(false); + const containerRef = React.useRef(null); + + return ( +
+
+

Custom announcer container (announcements will be rendered here)

+
+ + + + + + Custom Container Toast + + This toast's announcements are rendered in a custom container + + Close + + + + +
+ ); +}; + const SNAPSHOT_DELAY = 300; export const Chromatic = () => { diff --git a/cypress/e2e/Toast.cy.ts b/cypress/e2e/Toast.cy.ts index d1af094b0..d0d4eb813 100644 --- a/cypress/e2e/Toast.cy.ts +++ b/cypress/e2e/Toast.cy.ts @@ -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'); + }); + }); + + }); + }); diff --git a/packages/react/toast/src/toast.tsx b/packages/react/toast/src/toast.tsx index 25f27f402..8ab7f02f8 100644 --- a/packages/react/toast/src/toast.tsx +++ b/packages/react/toast/src/toast.tsx @@ -36,6 +36,7 @@ type ToastProviderContextValue = { onToastRemove(): void; isFocusedToastEscapeKeyDownRef: React.MutableRefObject; isClosePausedRef: React.MutableRefObject; + announcerContainer?: Element | DocumentFragment; }; type ScopedProps

= P & { __scopeToast?: Scope }; @@ -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 = (props: ScopedProps) => { @@ -75,6 +83,7 @@ const ToastProvider: React.FC = (props: ScopedProps(null); @@ -103,6 +112,7 @@ const ToastProvider: React.FC = (props: ScopedProps setToastCount((prevCount) => prevCount - 1), [])} isFocusedToastEscapeKeyDownRef={isFocusedToastEscapeKeyDownRef} isClosePausedRef={isClosePausedRef} + announcerContainer={announcerContainer} > {children} @@ -686,7 +696,7 @@ const ToastAnnounce: React.FC = (props: ScopedProps + {renderAnnounceText && ( <> From a0b5505fd56e74488d26fd5887cf3a887d2aeabb Mon Sep 17 00:00:00 2001 From: Ihor Tsvietkov Date: Tue, 26 Aug 2025 13:48:45 +0300 Subject: [PATCH 2/2] changelog --- .changeset/little-readers-drum.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/little-readers-drum.md diff --git a/.changeset/little-readers-drum.md b/.changeset/little-readers-drum.md new file mode 100644 index 000000000..80f401c77 --- /dev/null +++ b/.changeset/little-readers-drum.md @@ -0,0 +1,5 @@ +--- +'@radix-ui/react-toast': patch +--- + +Allow to specify container for ToastAnnounce \ No newline at end of file