diff --git a/packages/design-system/src/components/ButtonBase/classes.ts b/packages/design-system/src/components/ButtonBase/classes.ts index 3c6efd4a7..9cd411739 100644 --- a/packages/design-system/src/components/ButtonBase/classes.ts +++ b/packages/design-system/src/components/ButtonBase/classes.ts @@ -3,8 +3,8 @@ import { cn } from '../../utils/cn'; export const buttonBaseVariants = cva( cn( - 'items-center justify-center min-w-0 rounded-8', - 'text-sm font-medium font-sans', + 'items-center justify-center min-w-0 shrink-0 rounded-8', + 'text-sm font-medium font-sans whitespace-nowrap', 'cursor-pointer overflow-hidden transition-[color,border,box-shadow,opacity]', 'focus-visible:outline-none focus-visible:ring-3', 'data-[focus=true]:outline-none data-[focus=true]:ring-3', @@ -31,7 +31,9 @@ export const buttonBaseVariants = cva( false: '', }, fullWidth: { - true: 'flex flex-1 basis-full w-full', + // shrink re-enables shrinking (overrides base shrink-0 via cn) so + // multiple fullWidth buttons can split a row without overflowing + true: 'flex flex-1 shrink basis-full w-full', false: 'inline-flex', }, }, diff --git a/packages/design-system/src/components/Code/Code.tsx b/packages/design-system/src/components/Code/Code.tsx index 1537ecf80..f2d73eb34 100644 --- a/packages/design-system/src/components/Code/Code.tsx +++ b/packages/design-system/src/components/Code/Code.tsx @@ -20,6 +20,7 @@ const codeVariants = cva('font-mono', { }, color: { primary: 'text-text-primary', + 'primary-alt': 'text-text-primary-alt', secondary: 'text-text-secondary', destructive: 'text-text-danger', }, diff --git a/packages/design-system/src/components/Table/lib/createSelectionColumn.tsx b/packages/design-system/src/components/Table/lib/createSelectionColumn.tsx index a0184c884..04a33fa14 100644 --- a/packages/design-system/src/components/Table/lib/createSelectionColumn.tsx +++ b/packages/design-system/src/components/Table/lib/createSelectionColumn.tsx @@ -104,7 +104,7 @@ export const createSelectionColumn = (): ColumnDef => { enablePinning: false, meta: { headerClassName: 'px-8 py-4', - cellClassName: 'px-8 py-4', + cellClassName: 'px-8 py-8', }, header: ({ table }) => { const checked = table.getIsAllPageRowsSelected(); diff --git a/packages/design-system/src/components/Toast/Toast.e2e.ts b/packages/design-system/src/components/Toast/Toast.e2e.ts index d6600f8fa..843d94120 100644 --- a/packages/design-system/src/components/Toast/Toast.e2e.ts +++ b/packages/design-system/src/components/Toast/Toast.e2e.ts @@ -3,6 +3,7 @@ import { createStoryHelper } from '@wallarm-org/playwright-config/storybook'; const toastStory = createStoryHelper('messaging-toast', [ 'Basic', + 'With Nested Overlays', 'Update Loading To Success', 'Simple With Actions', 'Extended With Actions', @@ -200,4 +201,47 @@ test.describe('Toast Component', () => { await expect(page).toHaveScreenshot(); }); }); + + test.describe('Interactions', () => { + test('Should stack the toast above open drawer and nested dialog overlays', async ({ + page, + }) => { + // Regression: the toaster region's forced !z-[50] sat at or below the + // drawer/dialog positioners (50 + layer * 20) and nested overlays + // (40 + layer * 20), hiding toasts behind them. The region now sits on + // the dedicated top-tier --toast-z-index. + const expectFrontToastOnTop = async () => { + const toast = getToasts(page).first(); + await expect(toast).toBeVisible(); + + // The topmost element at the toast's location must belong to the + // toast itself — not to a drawer, dialog, or overlay covering it. + // Polled because the toast slides in: until the enter transition + // settles its center may still be outside the viewport. + await expect + .poll(() => + toast.evaluate(el => { + const rect = el.getBoundingClientRect(); + const top = document.elementFromPoint( + rect.left + rect.width / 2, + rect.top + rect.height / 2, + ); + return !!top && el.contains(top); + }), + ) + .toBe(true); + }; + + await toastStory.goto(page, 'With Nested Overlays'); + + await page.getByRole('button', { name: 'Open drawer' }).click(); + await page.getByRole('button', { name: 'Show toast from drawer' }).click(); + await expectFrontToastOnTop(); + + await page.getByRole('button', { name: 'Open nested dialog' }).click(); + await page.getByRole('button', { name: 'Show toast from nested dialog' }).click(); + await expect(getToasts(page)).toHaveCount(2); + await expectFrontToastOnTop(); + }); + }); }); diff --git a/packages/design-system/src/components/Toast/Toast.stories.tsx b/packages/design-system/src/components/Toast/Toast.stories.tsx index b9fcd889a..9bf1d2b9b 100644 --- a/packages/design-system/src/components/Toast/Toast.stories.tsx +++ b/packages/design-system/src/components/Toast/Toast.stories.tsx @@ -2,6 +2,22 @@ import { useRef } from 'react'; import type { Meta, StoryFn } from 'storybook-react-rsbuild'; import { Activity } from '../../icons'; import { Button } from '../Button'; +import { + Dialog, + DialogBody, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '../Dialog'; +import { + Drawer, + DrawerBody, + DrawerContent, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from '../Drawer'; import { HStack, VStack } from '../Stack'; import { Text } from '../Text'; import { ToastActions, Toaster, useToast } from './index'; @@ -119,6 +135,60 @@ export const Basic: StoryFn = () => { return ; }; +export const WithNestedOverlays: StoryFn = () => { + const toast = useToast(); + + const fireToast = () => + toast.create({ + title: 'Saved', + description: 'This toast must render above every open overlay.', + type: 'success', + duration: STATIC_DURATION, + }); + + return ( + + + + + + + + [Level 1] Drawer + + + + + + + + + + + + + + [Level 2] Dialog + + + + + + + + + + + + ); +}; + export const UpdateLoadingToSuccess: StoryFn = () => { const toast = useToast(); const id = useRef(undefined); diff --git a/packages/design-system/src/components/Toast/Toaster.tsx b/packages/design-system/src/components/Toast/Toaster.tsx index 9f2666ce8..54470f3d7 100644 --- a/packages/design-system/src/components/Toast/Toaster.tsx +++ b/packages/design-system/src/components/Toast/Toaster.tsx @@ -104,7 +104,12 @@ export const Toaster: FC = () => { // center it so pausing tracks the toast column, not the full viewport width. // (These were previously on a `[data-part=group]` descendant selector that // never matched — the region element itself carries data-part="group".) - 'fixed bottom-0 right-0 !z-[50] flex flex-col gap-12 p-24 max-w-[560px] mx-auto', + // The `!` on z is required to beat zag's inline zIndex on the region; + // --toast-z-index keeps toasts above any drawer/dialog stack and its + // overlays (positioners compute 50 + layer * 20) while staying below + // tooltips. Safe to raise: zag sets pointer-events: none on the + // region while it holds no toasts. + 'fixed bottom-0 right-0 !z-(--toast-z-index) flex flex-col gap-12 p-24 max-w-[560px] mx-auto', // Root styles with CSS variables for animations '[&_[data-scope=toast][data-part=root]]:[translate:var(--x)_var(--y)] [&_[data-scope=toast][data-part=root]]:scale-[var(--scale)] [&_[data-scope=toast][data-part=root]]:z-[var(--z-index)] [&_[data-scope=toast][data-part=root]]:h-[var(--height)] [&_[data-scope=toast][data-part=root]]:opacity-[var(--opacity)] [&_[data-scope=toast][data-part=root]]:[will-change:translate,opacity,scale]', // Match Drawer/Dialog timings: 300ms open, 150ms close diff --git a/packages/design-system/src/components/Tooltip/Tooltip.e2e.ts b/packages/design-system/src/components/Tooltip/Tooltip.e2e.ts index b07054ffd..c784ffdf7 100644 --- a/packages/design-system/src/components/Tooltip/Tooltip.e2e.ts +++ b/packages/design-system/src/components/Tooltip/Tooltip.e2e.ts @@ -5,6 +5,7 @@ const tooltipStory = createStoryHelper('overlay-tooltip', [ 'Basic', 'With Description', 'With Kbd', + 'With Nested Dialog', ] as const); const getTooltipTrigger = (page: Page) => page.getByTestId('tooltip--trigger'); @@ -44,6 +45,34 @@ test.describe('Component: Tooltip', () => { await expect(getTooltipContent(page)).toHaveText('Right'); }); + test('Should stack the tooltip above a nested dialog when hovered inside it', async ({ + page, + }) => { + // Regression: the tooltip content's static z-50 mirrored into its + // positioner sat below the nested dialog positioner (50 + layer * 20), + // hiding the open tooltip. Tooltips never join zag's dismissable layer + // stack, so the content uses the dedicated top-tier --tooltip-z-index. + await tooltipStory.goto(page, 'With Nested Dialog'); + await page.getByRole('button', { name: 'Open dialog with nested tooltip' }).click(); + await page.getByRole('button', { name: 'Open nested dialog' }).click(); + await page.getByTestId('nested-tooltip--trigger').hover(); + + const content = page.getByTestId('nested-tooltip--content'); + await expect(content).toBeVisible(); + + // The topmost element at the tooltip's location must belong to the + // tooltip itself — not to the nested dialog covering it. + const isOnTop = await content.evaluate(el => { + const rect = el.getBoundingClientRect(); + const top = document.elementFromPoint( + rect.left + rect.width / 2, + rect.top + rect.height / 2, + ); + return !!top && el.contains(top); + }); + expect(isOnTop).toBe(true); + }); + test('Should hide tooltip when mouse leaves trigger', async ({ page }) => { await tooltipStory.goto(page, 'Basic'); await getTooltipTrigger(page).hover(); diff --git a/packages/design-system/src/components/Tooltip/Tooltip.stories.tsx b/packages/design-system/src/components/Tooltip/Tooltip.stories.tsx index e117b6512..352ee4f0e 100644 --- a/packages/design-system/src/components/Tooltip/Tooltip.stories.tsx +++ b/packages/design-system/src/components/Tooltip/Tooltip.stories.tsx @@ -1,6 +1,14 @@ import type { Meta, StoryFn } from 'storybook-react-rsbuild'; import { ChevronRight } from '../../icons'; import { Button } from '../Button'; +import { + Dialog, + DialogBody, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '../Dialog'; import { Kbd } from '../Kbd'; import { Text } from '../Text'; import { Tooltip } from './Tooltip'; @@ -45,6 +53,47 @@ export const WithDescription: StoryFn = () => ( ); +export const WithNestedDialog: StoryFn = () => ( + + + + + + + + [Level 1] Main Dialog + + + + + + + + + + + [Level 2] With Tooltip + + + + + + + + Tooltip above the dialog + + + + + + + +); + export const WithKbd: StoryFn = () => ( diff --git a/packages/design-system/src/components/Tooltip/TooltipContent.tsx b/packages/design-system/src/components/Tooltip/TooltipContent.tsx index 265b4310b..70870ea5c 100644 --- a/packages/design-system/src/components/Tooltip/TooltipContent.tsx +++ b/packages/design-system/src/components/Tooltip/TooltipContent.tsx @@ -10,8 +10,13 @@ const tooltipContentVariants = cva( [ // Base 'py-4 rounded-8 bg-component-tooltip-bg text-text-primary-alt text-xs font-medium', - // Behavior - 'z-50 overflow-hidden', + // Behavior: tooltips sit on a dedicated top tier above nested + // drawer/dialog positioners (50 + layer * 20). They can't reuse the + // layer-aware formula from DropdownMenu/Popover because zag never + // registers tooltips in its dismissable layer stack, so --layer-index + // is never set on this node. Zag's popper mirrors the computed z-index + // into the portal positioner's --z-index, so the value lives here. + 'z-(--tooltip-z-index) overflow-hidden', // Animation base 'animate-in fade-in-0 zoom-in-95 origin-[--transform-origin]', // Animation closed diff --git a/packages/design-system/src/theme/components/toast.css b/packages/design-system/src/theme/components/toast.css new file mode 100644 index 000000000..3acaf999e --- /dev/null +++ b/packages/design-system/src/theme/components/toast.css @@ -0,0 +1,8 @@ +:root { + /* Toasts are viewport-level notifications that must stay visible above + any open drawer/dialog stack and its overlays (positioners compute + 50 + layer * 20, overlays 40 + layer * 20), so they sit on a dedicated + top tier. Kept below --tooltip-z-index (1000) so tooltips triggered + from toast controls still render above the toast itself. */ + --toast-z-index: 900; +} diff --git a/packages/design-system/src/theme/components/tooltip.css b/packages/design-system/src/theme/components/tooltip.css new file mode 100644 index 000000000..4f520cc71 --- /dev/null +++ b/packages/design-system/src/theme/components/tooltip.css @@ -0,0 +1,8 @@ +:root { + /* Tooltips never join zag's dismissable layer stack (unlike menus, + selects, popovers, and dialogs), so they cannot derive a layer-aware + z-index from --layer-index. Instead they live on a dedicated top tier + above any realistic overlay stack — drawer/dialog positioners compute + 50 + layer * 20, so 1000 clears dozens of nested levels. */ + --tooltip-z-index: 1000; +} diff --git a/packages/design-system/src/theme/index.css b/packages/design-system/src/theme/index.css index fce179410..64c2b333b 100644 --- a/packages/design-system/src/theme/index.css +++ b/packages/design-system/src/theme/index.css @@ -19,6 +19,8 @@ @import './utilities/code-snippet-bg.css'; @import './components/drawer.css'; @import './components/dialog.css'; +@import './components/tooltip.css'; +@import './components/toast.css'; @import './components/tour.css'; @import './components/skeleton.css'; @import './components/accordion.css';