From 383ea96eb14e4f72697280de2627ca52ba6c3378 Mon Sep 17 00:00:00 2001 From: ShreyasGit51283 Date: Tue, 23 Jun 2026 21:26:14 +0530 Subject: [PATCH 1/6] fix(components): prevent overlay subtitle slot from leaking into nested Alert When an Alert is nested inside a Dialog/Modal, the overlay's `subtitle` slot context captured the Alert's Text: it forced `slot="subtitle"`, rendered the text as the Dialog's `h3` description, and applied the overlay's subtitle CSS (wrong color/size/position). Alert now establishes its own TextContext (default + subtitle slots), and the Modal subtitle rule is scoped to skip alert-nested text. Adds regression tests and a nested-overlay story. DSYS-157 Co-authored-by: Cursor --- .changeset/alert-nested-overlay-slot-leak.md | 5 ++ packages/components/__tests__/Alert.spec.tsx | 41 ++++++++++++- packages/components/src/Alert.tsx | 16 +++++- .../components/src/styles/Modal.module.css | 5 +- packages/components/stories/Alert.stories.tsx | 57 +++++++++++++++++++ 5 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 .changeset/alert-nested-overlay-slot-leak.md diff --git a/.changeset/alert-nested-overlay-slot-leak.md b/.changeset/alert-nested-overlay-slot-leak.md new file mode 100644 index 000000000..a7737a82d --- /dev/null +++ b/.changeset/alert-nested-overlay-slot-leak.md @@ -0,0 +1,5 @@ +--- +"@launchpad-ui/components": patch +--- + +Fix `Alert` text styling and semantics leaking when nested inside an overlay (`Dialog`/`Modal`). `Alert` now establishes its own `TextContext`, so an ancestor overlay's `subtitle` slot no longer captures the Alert's `Text` (which previously forced `slot="subtitle"`, inherited the overlay's `id`/`h3` element, and picked up the overlay's subtitle CSS). The `Modal` subtitle rule is also scoped to skip alert-nested text. A bare `` now works inside an `Alert`, including within a `Dialog`. diff --git a/packages/components/__tests__/Alert.spec.tsx b/packages/components/__tests__/Alert.spec.tsx index a4ecf97ee..4fe05897e 100644 --- a/packages/components/__tests__/Alert.spec.tsx +++ b/packages/components/__tests__/Alert.spec.tsx @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { render, screen, userEvent } from '../../../test/utils'; -import { Alert, Heading, Text } from '../src'; +import { Alert, Dialog, Heading, Text } from '../src'; describe('Alert', () => { it('renders', () => { @@ -28,4 +28,43 @@ describe('Alert', () => { expect(spy).toHaveBeenCalledTimes(1); expect(screen.queryByRole('alert')).toBeNull(); }); + + // Regression coverage for DSYS-157: a Dialog publishes a `subtitle` slot context + // (with its own id/elementType/CSS). Alert must establish its own Text context so a + // nested isn't captured by the Dialog's subtitle slot. + describe('nested inside a Dialog (DSYS-157)', () => { + it('does not let the Dialog subtitle context leak into the Alert', () => { + render( + + Dialog body + + Alert heading + Alert body text + + , + ); + + // The Dialog must not adopt the Alert's text as its accessible description. + expect(screen.queryByRole('dialog', { description: 'Alert body text' })).toBeNull(); + + // The Alert's text must not be promoted to the Dialog's subtitle element (h3). + expect(screen.getByText('Alert body text').tagName).not.toBe('H3'); + }); + + it('allows a bare inside a nested Alert without a forced slot', () => { + expect(() => + render( + + Dialog body + + Alert heading + Bare alert text + + , + ), + ).not.toThrow(); + + expect(screen.getByText('Bare alert text')).toBeVisible(); + }); + }); }); diff --git a/packages/components/src/Alert.tsx b/packages/components/src/Alert.tsx index af65a3ce1..d7af0cb44 100644 --- a/packages/components/src/Alert.tsx +++ b/packages/components/src/Alert.tsx @@ -5,7 +5,8 @@ import { StatusIcon } from '@launchpad-ui/icons'; import { useControlledState } from '@react-stately/utils'; import { cva } from 'class-variance-authority'; import { HeadingContext } from 'react-aria-components/Heading'; -import { Provider } from 'react-aria-components/slots'; +import { DEFAULT_SLOT, Provider } from 'react-aria-components/slots'; +import { TextContext } from 'react-aria-components/Text'; import { ButtonGroupContext } from './ButtonGroup'; import { IconButton } from './IconButton'; @@ -99,6 +100,19 @@ const Alert = ({ values={[ [HeadingContext, { className: styles.heading }], [ButtonGroupContext, { className: styles.buttonGroup }], + // Establish Alert's own Text context so an ancestor overlay (e.g. a Dialog, + // which sets a `subtitle` slot with its own id/elementType/CSS) cannot leak + // into Alert content. Provides a default slot so a bare works, plus a + // `subtitle` slot for backward compatibility with existing usage. + [ + TextContext, + { + slots: { + [DEFAULT_SLOT]: {}, + subtitle: {}, + }, + }, + ], ]} > {children} diff --git a/packages/components/src/styles/Modal.module.css b/packages/components/src/styles/Modal.module.css index 608fd84f8..f5d32d6bd 100644 --- a/packages/components/src/styles/Modal.module.css +++ b/packages/components/src/styles/Modal.module.css @@ -84,7 +84,10 @@ gap: var(--lp-size-10); } - & [slot='subtitle'] { + /* Scope to the overlay's own subtitle. The `:not()` keeps this from reaching into a + nested Alert's slotted text, which reuses the `subtitle` slot and would otherwise + inherit the overlay's title/subtitle grid styling. */ + & [slot='subtitle']:not([role='alert'] *) { font: var(--lp-text-body-1-regular); color: var(--lp-color-text-ui-secondary); grid-row: 2; diff --git a/packages/components/stories/Alert.stories.tsx b/packages/components/stories/Alert.stories.tsx index ce4900c29..28b777dc1 100644 --- a/packages/components/stories/Alert.stories.tsx +++ b/packages/components/stories/Alert.stories.tsx @@ -1,9 +1,13 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; + import { Alert, AlertText } from '../src/Alert'; import { Button } from '../src/Button'; import { ButtonGroup } from '../src/ButtonGroup'; +import { Dialog, DialogTrigger } from '../src/Dialog'; import { Heading } from '../src/Heading'; +import { Modal, ModalOverlay } from '../src/Modal'; import { Text } from '../src/Text'; const meta: Meta = { @@ -236,3 +240,56 @@ export const InlineTones: Story = { ), name: 'Inline/Tones', }; + +// ============================================================================= +// Nested overlay regression (DSYS-157) +// ============================================================================= + +/** + * Regression coverage for DSYS-157. When Alerts are nested inside a Dialog/Modal, the + * overlay's `subtitle` slot context (styling + semantics) must not leak into the Alert's + * text. The Dialog's own subtitle keeps the overlay styling, while both nested Alerts + * render with the Alert's body-text styling — identical to how they look outside a modal. + */ +export const NestedInDialog: Story = { + render: () => ( + + + + + + Archive flag? + + The Dialog's own subtitle — this keeps the overlay subtitle styling. + + + Heads up before archiving + + Scenario alert body. Inside the dialog this must use the Alert's text styling, not + the Dialog's subtitle styling. + + + + No critical environments to check + {/* Bare now works inside an Alert, even within a Dialog. */} + + No checks were run because you don't have any critical environments set up. + + + + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button')); + const body = canvasElement.ownerDocument.body; + await waitFor(async () => { + expect(await within(body).findByRole('dialog')).toBeVisible(); + }); + }, + name: 'Nested in Dialog (DSYS-157)', +}; From a91b5a1b9622d294cbc401607b3eef30f33de865 Mon Sep 17 00:00:00 2001 From: Zakk Verrilli Date: Thu, 25 Jun 2026 08:44:21 -0400 Subject: [PATCH 2/6] refactor(components): scope overlay subtitle styles to the dialog's own slots Replace the global `[slot='subtitle']:not([role='alert'] *)` selector with dialog-scoped selectors (`[role='dialog'] > [slot='subtitle']` and `[slot='header'] > [slot='subtitle']`), declared once and shared via a single selector list. This targets the overlay's own subtitle in both the header and headerless layouts without the `:not()` guard, and cannot reach a nested Alert that reuses the `subtitle` slot. --- .../components/src/styles/Modal.module.css | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/components/src/styles/Modal.module.css b/packages/components/src/styles/Modal.module.css index f5d32d6bd..6a7498357 100644 --- a/packages/components/src/styles/Modal.module.css +++ b/packages/components/src/styles/Modal.module.css @@ -61,6 +61,14 @@ height: 100%; } + & [role='dialog'] > [slot='subtitle'], + & [slot='header'] > [slot='subtitle'] { + font: var(--lp-text-body-1-regular); + color: var(--lp-color-text-ui-secondary); + grid-row: 2; + grid-column: 1 / span 2; + } + & [slot='header'] { display: grid; grid-template-columns: 1fr; @@ -83,16 +91,6 @@ align-items: center; gap: var(--lp-size-10); } - - /* Scope to the overlay's own subtitle. The `:not()` keeps this from reaching into a - nested Alert's slotted text, which reuses the `subtitle` slot and would otherwise - inherit the overlay's title/subtitle grid styling. */ - & [slot='subtitle']:not([role='alert'] *) { - font: var(--lp-text-body-1-regular); - color: var(--lp-color-text-ui-secondary); - grid-row: 2; - grid-column: 1 / span 2; - } } .defaultSmall { From 434db59023888e456b6aa7b97ff77733e51f1ae5 Mon Sep 17 00:00:00 2001 From: Zakk Verrilli Date: Thu, 25 Jun 2026 08:49:13 -0400 Subject: [PATCH 3/6] test(components): relocate overlay story coverage from Alert to Modal Remove the nested-overlay regression story (and its now-unused imports) from Alert.stories, and add a `WithHeader` story to Modal.stories so the Dialog/Modal header layout is demonstrated alongside the Modal component. The header lives inside the Dialog render prop so the `subtitle` slot context and the dialog's close handler resolve correctly. --- packages/components/stories/Alert.stories.tsx | 57 ------------------- packages/components/stories/Modal.stories.tsx | 42 ++++++++++++++ 2 files changed, 42 insertions(+), 57 deletions(-) diff --git a/packages/components/stories/Alert.stories.tsx b/packages/components/stories/Alert.stories.tsx index 28b777dc1..ce4900c29 100644 --- a/packages/components/stories/Alert.stories.tsx +++ b/packages/components/stories/Alert.stories.tsx @@ -1,13 +1,9 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, userEvent, waitFor, within } from 'storybook/test'; - import { Alert, AlertText } from '../src/Alert'; import { Button } from '../src/Button'; import { ButtonGroup } from '../src/ButtonGroup'; -import { Dialog, DialogTrigger } from '../src/Dialog'; import { Heading } from '../src/Heading'; -import { Modal, ModalOverlay } from '../src/Modal'; import { Text } from '../src/Text'; const meta: Meta = { @@ -240,56 +236,3 @@ export const InlineTones: Story = { ), name: 'Inline/Tones', }; - -// ============================================================================= -// Nested overlay regression (DSYS-157) -// ============================================================================= - -/** - * Regression coverage for DSYS-157. When Alerts are nested inside a Dialog/Modal, the - * overlay's `subtitle` slot context (styling + semantics) must not leak into the Alert's - * text. The Dialog's own subtitle keeps the overlay styling, while both nested Alerts - * render with the Alert's body-text styling — identical to how they look outside a modal. - */ -export const NestedInDialog: Story = { - render: () => ( - - - - - - Archive flag? - - The Dialog's own subtitle — this keeps the overlay subtitle styling. - - - Heads up before archiving - - Scenario alert body. Inside the dialog this must use the Alert's text styling, not - the Dialog's subtitle styling. - - - - No critical environments to check - {/* Bare now works inside an Alert, even within a Dialog. */} - - No checks were run because you don't have any critical environments set up. - - - - - - - ), - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await userEvent.click(canvas.getByRole('button')); - const body = canvasElement.ownerDocument.body; - await waitFor(async () => { - expect(await within(body).findByRole('dialog')).toBeVisible(); - }); - }, - name: 'Nested in Dialog (DSYS-157)', -}; diff --git a/packages/components/stories/Modal.stories.tsx b/packages/components/stories/Modal.stories.tsx index 12ee1d4fd..e8753c306 100644 --- a/packages/components/stories/Modal.stories.tsx +++ b/packages/components/stories/Modal.stories.tsx @@ -112,6 +112,48 @@ export const Drawer: Story = { }, }; +export const WithHeader: Story = { + render: (args) => ( + + + + + + {({ close }) => ( + <> +
+ Invite teammates + + They'll get an email with a link to join. +
+
Body text
+
+ + +
+ + )} +
+
+
+
+ ), + play, + parameters: { + chromatic: { + modes: { + mobile: allModes.mobile, + }, + }, + }, +}; + /** * Bug reproduction: Dialog closes when clicking a button inside it while Toast is active. * From 74b91dae2b2e6a3e0a7e40ce5e5e6481bd75dfe2 Mon Sep 17 00:00:00 2001 From: Zakk Verrilli Date: Thu, 25 Jun 2026 09:17:49 -0400 Subject: [PATCH 4/6] fix: move header slot; sync header slot cases; remove comment --- packages/components/src/Alert.tsx | 4 -- .../components/src/styles/Modal.module.css | 4 ++ packages/components/stories/Modal.stories.tsx | 49 ++++++++++--------- 3 files changed, 31 insertions(+), 26 deletions(-) diff --git a/packages/components/src/Alert.tsx b/packages/components/src/Alert.tsx index d7af0cb44..01a0a0998 100644 --- a/packages/components/src/Alert.tsx +++ b/packages/components/src/Alert.tsx @@ -100,10 +100,6 @@ const Alert = ({ values={[ [HeadingContext, { className: styles.heading }], [ButtonGroupContext, { className: styles.buttonGroup }], - // Establish Alert's own Text context so an ancestor overlay (e.g. a Dialog, - // which sets a `subtitle` slot with its own id/elementType/CSS) cannot leak - // into Alert content. Provides a default slot so a bare works, plus a - // `subtitle` slot for backward compatibility with existing usage. [ TextContext, { diff --git a/packages/components/src/styles/Modal.module.css b/packages/components/src/styles/Modal.module.css index 6a7498357..e0f97fe93 100644 --- a/packages/components/src/styles/Modal.module.css +++ b/packages/components/src/styles/Modal.module.css @@ -69,6 +69,10 @@ grid-column: 1 / span 2; } + > [slot='header'] { + margin-bottom: var(--lp-size-20); + } + & [slot='header'] { display: grid; grid-template-columns: 1fr; diff --git a/packages/components/stories/Modal.stories.tsx b/packages/components/stories/Modal.stories.tsx index e8753c306..e09d2d384 100644 --- a/packages/components/stories/Modal.stories.tsx +++ b/packages/components/stories/Modal.stories.tsx @@ -47,20 +47,20 @@ const renderModal = (args: Story['args']) => ( +
+ Title + + Subtitle +
{({ close }) => ( <> -
- Title - - Subtitle -
Body text
@@ -112,26 +112,31 @@ export const Drawer: Story = { }, }; +/** + * A Modal with a slotted `header`. The `header` groups the `title`, an optional + * `subtitle`, and the close button into the overlay's header grid, separate from the + * `body` and `footer`. + */ export const WithHeader: Story = { render: (args) => ( +
+ Invite teammates + + They'll get an email with a link to join. +
{({ close }) => ( <> -
- Invite teammates - - They'll get an email with a link to join. -
Body text
From 65331ed76d45d6a0487ac353c37c1a858b198ba7 Mon Sep 17 00:00:00 2001 From: Zakk Verrilli Date: Thu, 25 Jun 2026 09:25:13 -0400 Subject: [PATCH 5/6] test(components): add Alert to nested Modal story --- packages/components/stories/Modal.stories.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/components/stories/Modal.stories.tsx b/packages/components/stories/Modal.stories.tsx index e09d2d384..4a33017d0 100644 --- a/packages/components/stories/Modal.stories.tsx +++ b/packages/components/stories/Modal.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, ReactRenderer, StoryObj } from '@storybook/react-vite'; import type { ComponentType } from 'react'; import type { PlayFunction } from 'storybook/internal/types'; +import { Alert } from '@launchpad-ui/components'; import { expect, userEvent, waitFor, within } from 'storybook/test'; import { allModes } from '../../../.storybook/modes'; @@ -133,6 +134,13 @@ export const WithHeader: Story = { onPress={close} /> They'll get an email with a link to join. + + Heads up before archiving + + Scenario alert body. Inside the dialog this must use the Alert's text styling, not + the Dialog's subtitle styling. + +
{({ close }) => ( @@ -193,6 +201,14 @@ export const DialogWithActiveToast: Story = { onPress={close} /> Try clicking the button below with toast active + + No critical environments to check + {/* Bare now works inside an Alert, even within a Dialog. */} + + No checks were run because you don't have any critical environments set + up. + +

From 30fc2cdd0a1f695253d48179b221fd2441e5544f Mon Sep 17 00:00:00 2001 From: Zakk Verrilli Date: Thu, 25 Jun 2026 09:41:30 -0400 Subject: [PATCH 6/6] test(components): move header slot back into Dialog --- packages/components/stories/Modal.stories.tsx | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/packages/components/stories/Modal.stories.tsx b/packages/components/stories/Modal.stories.tsx index 4a33017d0..f412620e3 100644 --- a/packages/components/stories/Modal.stories.tsx +++ b/packages/components/stories/Modal.stories.tsx @@ -48,20 +48,20 @@ const renderModal = (args: Story['args']) => ( -

- Title - - Subtitle -
{({ close }) => ( <> +
+ Title + + Subtitle +
Body text
@@ -124,27 +124,27 @@ export const WithHeader: Story = { -
- Invite teammates - - They'll get an email with a link to join. - - Heads up before archiving - - Scenario alert body. Inside the dialog this must use the Alert's text styling, not - the Dialog's subtitle styling. - - -
{({ close }) => ( <> +
+ Invite teammates + + They'll get an email with a link to join. + + Heads up before archiving + + Scenario alert body. Inside the dialog this must use the Alert's text styling, + not the Dialog's subtitle styling. + + +
Body text