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..01a0a0998 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,15 @@ const Alert = ({ values={[ [HeadingContext, { className: styles.heading }], [ButtonGroupContext, { className: styles.buttonGroup }], + [ + 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..e0f97fe93 100644 --- a/packages/components/src/styles/Modal.module.css +++ b/packages/components/src/styles/Modal.module.css @@ -61,6 +61,18 @@ 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'] { + margin-bottom: var(--lp-size-20); + } + & [slot='header'] { display: grid; grid-template-columns: 1fr; @@ -83,13 +95,6 @@ align-items: center; gap: var(--lp-size-10); } - - & [slot='subtitle'] { - font: var(--lp-text-body-1-regular); - color: var(--lp-color-text-ui-secondary); - grid-row: 2; - grid-column: 1 / span 2; - } } .defaultSmall { diff --git a/packages/components/stories/Modal.stories.tsx b/packages/components/stories/Modal.stories.tsx index 12ee1d4fd..f412620e3 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'; @@ -112,6 +113,60 @@ 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) => ( + + + + + + {({ 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
+
+ + +
+ + )} +
+
+
+
+ ), + play, + parameters: { + chromatic: { + modes: { + mobile: allModes.mobile, + }, + }, + }, +}; + /** * Bug reproduction: Dialog closes when clicking a button inside it while Toast is active. * @@ -146,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. + +