refactor(web): remove highPriority modal stacking (#35132)

This commit is contained in:
yyh 2026-04-14 12:22:25 +08:00 committed by GitHub
parent 79c1473378
commit 21ab9b9d8c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 52 additions and 102 deletions

View File

@ -135,19 +135,6 @@ describe('Modal', () => {
expect(container).toBeInTheDocument()
})
it('should apply highPriority z-index when highPriority is true', async () => {
await act(async () => {
render(
<Modal isShow={true} title="Test Modal" highPriority={true}>
<div>Content</div>
</Modal>,
)
})
const dialog = document.querySelector('.z-1100')
expect(dialog).toBeInTheDocument()
})
it('should apply overlayOpacity background when overlayOpacity is true', async () => {
await act(async () => {
render(

View File

@ -9,7 +9,7 @@ const meta = {
layout: 'fullscreen',
docs: {
description: {
component: 'Lightweight modal wrapper with optional header/description, close icon, and high-priority stacking for dropdown overlays.',
component: 'Lightweight modal wrapper with optional header/description and close icon.',
},
},
},
@ -43,10 +43,6 @@ const meta = {
control: 'boolean',
description: 'Allows content to overflow the modal panel.',
},
highPriority: {
control: 'boolean',
description: 'Lifts the modal above other high z-index elements like dropdowns.',
},
onClose: {
control: false,
description: 'Callback invoked when the modal requests to close.',
@ -115,18 +111,17 @@ export const Default: Story = {
render: args => <ModalDemo {...args} />,
}
export const HighPriorityOverflow: Story = {
export const OverflowVisible: Story = {
render: args => <ModalDemo {...args} />,
args: {
highPriority: true,
overflowVisible: true,
description: 'Demonstrates the modal configured to sit above dropdowns while letting the body content overflow.',
description: 'Demonstrates the modal configured to let the body content overflow.',
className: 'max-w-[540px]',
},
parameters: {
docs: {
description: {
story: 'Shows the modal with `highPriority` and `overflowVisible` enabled, useful when nested within complex surfaces.',
story: 'Shows the modal with `overflowVisible` enabled for content that needs to escape the panel bounds.',
},
},
},

View File

@ -20,7 +20,6 @@ type IModal = {
children?: React.ReactNode
closable?: boolean
overflowVisible?: boolean
highPriority?: boolean // For modals that need to appear above dropdowns
overlayOpacity?: boolean // For semi-transparent overlay instead of default
clickOutsideNotClose?: boolean // Prevent closing when clicking outside modal
}
@ -36,13 +35,12 @@ export default function Modal({
children,
closable = false,
overflowVisible = false,
highPriority = false,
overlayOpacity = false,
clickOutsideNotClose = false,
}: IModal) {
return (
<Transition appear show={isShow} as={Fragment}>
<Dialog as="div" className={cn('relative', highPriority ? 'z-1100' : 'z-60', wrapperClassName)} onClose={clickOutsideNotClose ? noop : onClose}>
<Dialog as="div" className={cn('relative z-60', wrapperClassName)} onClose={clickOutsideNotClose ? noop : onClose}>
<TransitionChild>
<div className={cn('fixed inset-0', overlayOpacity ? 'bg-workflow-canvas-canvas-overlay' : 'bg-background-overlay', 'duration-300 ease-in data-closed:opacity-0', 'data-enter:opacity-100', 'data-leave:opacity-0')} />
</TransitionChild>
@ -59,19 +57,19 @@ export default function Modal({
{!!title && (
<DialogTitle
as="h3"
className="text-text-primary title-2xl-semi-bold"
className="title-2xl-semi-bold text-text-primary"
>
{title}
</DialogTitle>
)}
{!!description && (
<div className="mt-2 text-text-secondary body-md-regular">
<div className="mt-2 body-md-regular text-text-secondary">
{description}
</div>
)}
{closable
&& (
<div className="absolute right-6 top-6 z-10 flex h-5 w-5 items-center justify-center rounded-2xl hover:cursor-pointer hover:bg-state-base-hover">
<div className="absolute top-6 right-6 z-10 flex h-5 w-5 items-center justify-center rounded-2xl hover:cursor-pointer hover:bg-state-base-hover">
<span
className="i-ri-close-line h-4 w-4 text-text-tertiary"
onClick={

View File

@ -2,13 +2,10 @@
// z-index strategy (relies on root `isolation: isolate` in layout.tsx):
// All base/ui/* overlay primitives — z-1002
// Toast stays one layer above overlays at z-1003.
// Overlays share the same z-index; DOM order handles stacking when multiple are open.
// This ensures overlays inside a Dialog (e.g. a Tooltip on a dialog button) render
// above the dialog backdrop instead of being clipped by it.
// During migration, z-1002 is chosen to sit above all legacy overlays
// (Modal z-[60], PortalToFollowElem callers up to z-[1001]).
// Once all legacy overlays are migrated, this can be reduced back to z-50.
// Toast uses z-1101 during migration so it stays above legacy highPriority modals.
import { Dialog as BaseDialog } from '@base-ui/react/dialog'
import * as React from 'react'
@ -60,7 +57,7 @@ export function DialogContent({
<BaseDialog.Backdrop
{...backdropProps}
className={cn(
'inset-0 fixed z-1002 bg-background-overlay',
'fixed inset-0 z-1002 bg-background-overlay',
'transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none',
overlayClassName,
backdropProps?.className,

View File

@ -33,7 +33,7 @@ describe('base/ui/toast', () => {
expect(screen.getByText('Your changes are available now.')).toBeInTheDocument()
const viewport = screen.getByRole('region', { name: 'Notifications' })
expect(viewport).toHaveAttribute('aria-live', 'polite')
expect(viewport).toHaveClass('z-1101')
expect(viewport).toHaveClass('z-1003')
expect(viewport.firstElementChild).toHaveClass('top-4')
expect(screen.getByRole('dialog')).not.toHaveClass('outline-hidden')
expect(document.body.querySelector('[aria-hidden="true"].i-ri-checkbox-circle-fill')).toBeInTheDocument()

View File

@ -222,8 +222,7 @@ function ToastViewport() {
<BaseToast.Viewport
aria-label={toastViewportLabel}
className={cn(
// During overlay migration, toast must stay above legacy highPriority modals (z-[1100]).
'inset-0 group/toast-viewport pointer-events-none fixed z-1101 overflow-visible',
'group/toast-viewport pointer-events-none fixed inset-0 z-1003 overflow-visible',
)}
>
<div

View File

@ -4,7 +4,7 @@ import type { FC, KeyboardEvent } from 'react'
import { Command } from 'cmdk'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
import { Dialog, DialogContent } from '@/app/components/base/ui/dialog'
import InstallFromMarketplace from '../plugins/install-plugin/install-from-marketplace'
import { SlashCommandProvider } from './actions/commands'
import { slashCommandRegistry } from './actions/commands/registry'
@ -143,14 +143,14 @@ const GotoAnything: FC<Props> = ({
return (
<>
<SlashCommandProvider />
<Modal
isShow={show}
onClose={modalClose}
closable={false}
className="w-[480px]! p-0!"
highPriority={true}
<Dialog
open={show}
onOpenChange={(open) => {
if (!open)
modalClose()
}}
>
<div className="flex flex-col rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-xl">
<DialogContent className="w-[480px]! overflow-hidden p-0!">
<Command
className="outline-hidden"
value={cmdVal}
@ -219,8 +219,8 @@ const GotoAnything: FC<Props> = ({
hasQuery={!!searchQuery.trim()}
/>
</Command>
</div>
</Modal>
</DialogContent>
</Dialog>
{activePlugin && (
<InstallFromMarketplace

View File

@ -93,10 +93,10 @@ const NormalForm = () => {
<div className="rounded-lg bg-linear-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4">
<div className="shadows-shadow-lg relative mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow">
<RiContractLine className="h-5 w-5" />
<RiErrorWarningFill className="absolute -right-1 -top-1 h-4 w-4 text-text-warning-secondary" />
<RiErrorWarningFill className="absolute -top-1 -right-1 h-4 w-4 text-text-warning-secondary" />
</div>
<p className="text-text-primary system-sm-medium">{t('licenseLost', { ns: 'login' })}</p>
<p className="mt-1 text-text-tertiary system-xs-regular">{t('licenseLostTip', { ns: 'login' })}</p>
<p className="system-sm-medium text-text-primary">{t('licenseLost', { ns: 'login' })}</p>
<p className="mt-1 system-xs-regular text-text-tertiary">{t('licenseLostTip', { ns: 'login' })}</p>
</div>
</div>
</div>
@ -109,10 +109,10 @@ const NormalForm = () => {
<div className="rounded-lg bg-linear-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4">
<div className="shadows-shadow-lg relative mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow">
<RiContractLine className="h-5 w-5" />
<RiErrorWarningFill className="absolute -right-1 -top-1 h-4 w-4 text-text-warning-secondary" />
<RiErrorWarningFill className="absolute -top-1 -right-1 h-4 w-4 text-text-warning-secondary" />
</div>
<p className="text-text-primary system-sm-medium">{t('licenseExpired', { ns: 'login' })}</p>
<p className="mt-1 text-text-tertiary system-xs-regular">{t('licenseExpiredTip', { ns: 'login' })}</p>
<p className="system-sm-medium text-text-primary">{t('licenseExpired', { ns: 'login' })}</p>
<p className="mt-1 system-xs-regular text-text-tertiary">{t('licenseExpiredTip', { ns: 'login' })}</p>
</div>
</div>
</div>
@ -125,10 +125,10 @@ const NormalForm = () => {
<div className="rounded-lg bg-linear-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4">
<div className="shadows-shadow-lg relative mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow">
<RiContractLine className="h-5 w-5" />
<RiErrorWarningFill className="absolute -right-1 -top-1 h-4 w-4 text-text-warning-secondary" />
<RiErrorWarningFill className="absolute -top-1 -right-1 h-4 w-4 text-text-warning-secondary" />
</div>
<p className="text-text-primary system-sm-medium">{t('licenseInactive', { ns: 'login' })}</p>
<p className="mt-1 text-text-tertiary system-xs-regular">{t('licenseInactiveTip', { ns: 'login' })}</p>
<p className="system-sm-medium text-text-primary">{t('licenseInactive', { ns: 'login' })}</p>
<p className="mt-1 system-xs-regular text-text-tertiary">{t('licenseInactiveTip', { ns: 'login' })}</p>
</div>
</div>
</div>
@ -141,12 +141,12 @@ const NormalForm = () => {
{isInviteLink
? (
<div className="mx-auto w-full">
<h2 className="text-text-primary title-4xl-semi-bold">
<h2 className="title-4xl-semi-bold text-text-primary">
{t('join', { ns: 'login' })}
{workspaceName}
</h2>
{!systemFeatures.branding.enabled && (
<p className="mt-2 text-text-tertiary body-md-regular">
<p className="mt-2 body-md-regular text-text-tertiary">
{t('joinTipStart', { ns: 'login' })}
{workspaceName}
{t('joinTipEnd', { ns: 'login' })}
@ -156,8 +156,8 @@ const NormalForm = () => {
)
: (
<div className="mx-auto w-full">
<h2 className="text-text-primary title-4xl-semi-bold">{systemFeatures.branding.enabled ? t('pageTitleForE', { ns: 'login' }) : t('pageTitle', { ns: 'login' })}</h2>
<p className="mt-2 text-text-tertiary body-md-regular">{t('welcome', { ns: 'login' })}</p>
<h2 className="title-4xl-semi-bold text-text-primary">{systemFeatures.branding.enabled ? t('pageTitleForE', { ns: 'login' }) : t('pageTitle', { ns: 'login' })}</h2>
<p className="mt-2 body-md-regular text-text-tertiary">{t('welcome', { ns: 'login' })}</p>
</div>
)}
<div className="relative">
@ -174,7 +174,7 @@ const NormalForm = () => {
<div className="relative mt-6">
<div className="flex items-center">
<div className="h-px flex-1 bg-linear-to-r from-background-gradient-mask-transparent to-divider-regular"></div>
<span className="px-3 text-text-tertiary system-xs-medium-uppercase">{t('or', { ns: 'login' })}</span>
<span className="px-3 system-xs-medium-uppercase text-text-tertiary">{t('or', { ns: 'login' })}</span>
<div className="h-px flex-1 bg-linear-to-l from-background-gradient-mask-transparent to-divider-regular"></div>
</div>
</div>
@ -187,7 +187,7 @@ const NormalForm = () => {
<MailAndCodeAuth isInvite={isInviteLink} />
{systemFeatures.enable_email_password_login && (
<div className="cursor-pointer py-1 text-center" onClick={() => { updateAuthType('password') }}>
<span className="text-components-button-secondary-accent-text system-xs-medium">{t('usePassword', { ns: 'login' })}</span>
<span className="system-xs-medium text-components-button-secondary-accent-text">{t('usePassword', { ns: 'login' })}</span>
</div>
)}
</>
@ -197,18 +197,18 @@ const NormalForm = () => {
<MailAndPasswordAuth isInvite={isInviteLink} isEmailSetup={systemFeatures.is_email_setup} allowRegistration={systemFeatures.is_allow_register} />
{systemFeatures.enable_email_code_login && (
<div className="cursor-pointer py-1 text-center" onClick={() => { updateAuthType('code') }}>
<span className="text-components-button-secondary-accent-text system-xs-medium">{t('useVerificationCode', { ns: 'login' })}</span>
<span className="system-xs-medium text-components-button-secondary-accent-text">{t('useVerificationCode', { ns: 'login' })}</span>
</div>
)}
</>
)}
<Split className="mb-5 mt-4" />
<Split className="mt-4 mb-5" />
</>
)
}
{systemFeatures.is_allow_register && authType === 'password' && (
<div className="mb-3 text-[13px] font-medium leading-4 text-text-secondary">
<div className="mb-3 text-[13px] leading-4 font-medium text-text-secondary">
<span>{t('signup.noAccount', { ns: 'login' })}</span>
<Link
className="text-text-accent"
@ -224,8 +224,8 @@ const NormalForm = () => {
<div className="shadows-shadow-lg mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow">
<RiDoorLockLine className="h-5 w-5" />
</div>
<p className="text-text-primary system-sm-medium">{t('noLoginMethod', { ns: 'login' })}</p>
<p className="mt-1 text-text-tertiary system-xs-regular">{t('noLoginMethodTip', { ns: 'login' })}</p>
<p className="system-sm-medium text-text-primary">{t('noLoginMethod', { ns: 'login' })}</p>
<p className="mt-1 system-xs-regular text-text-tertiary">{t('noLoginMethodTip', { ns: 'login' })}</p>
</div>
<div className="relative my-2 py-2">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
@ -236,11 +236,11 @@ const NormalForm = () => {
)}
{!systemFeatures.branding.enabled && (
<>
<div className="mt-2 block w-full text-text-tertiary system-xs-regular">
<div className="mt-2 block w-full system-xs-regular text-text-tertiary">
{t('tosDesc', { ns: 'login' })}
&nbsp;
<Link
className="text-text-secondary system-xs-medium hover:underline"
className="system-xs-medium text-text-secondary hover:underline"
target="_blank"
rel="noopener noreferrer"
href="https://dify.ai/terms"
@ -249,7 +249,7 @@ const NormalForm = () => {
</Link>
&nbsp;&&nbsp;
<Link
className="text-text-secondary system-xs-medium hover:underline"
className="system-xs-medium text-text-secondary hover:underline"
target="_blank"
rel="noopener noreferrer"
href="https://dify.ai/privacy"
@ -258,11 +258,11 @@ const NormalForm = () => {
</Link>
</div>
{IS_CE_EDITION && (
<div className="w-hull mt-2 block text-text-tertiary system-xs-regular">
<div className="w-hull mt-2 block system-xs-regular text-text-tertiary">
{t('goToInit', { ns: 'login' })}
&nbsp;
<Link
className="text-text-secondary system-xs-medium hover:underline"
className="system-xs-medium text-text-secondary hover:underline"
href="/install"
>
{t('setAdminAccount', { ns: 'login' })}

View File

@ -64,7 +64,7 @@ pnpm -C web lint:fix --prune-suppressions <changed-files>
## z-index strategy
All new overlay primitives in `base/ui/` share a single z-index value:
**`z-1002`**, except Toast which stays at **`z-1101`** during migration.
**`z-1002`**, except Toast which stays one layer above at **`z-1003`**.
### Why z-[1002]?
@ -77,16 +77,15 @@ portal to `document.body` with explicit z-index values:
| Legacy Modal | `z-60` | `base/modal` (default) |
| Legacy PortalToFollowElem callers | up to `z-1001` | various business components |
| **New UI primitives** | **`z-1002`** | `base/ui/*` (Popover, Dialog, Tooltip, etc.) |
| Legacy Modal (highPriority) | `z-1100` | `base/modal` (`highPriority={true}`) |
| Toast | `z-1101` | `base/ui/toast` |
| Toast | `z-1003` | `base/ui/toast` |
`z-1002` sits above all common legacy overlays, so new primitives always
render on top without needing per-call-site z-index hacks. Among themselves,
new primitives share the same z-index and rely on **DOM order** for stacking
(later portal = on top).
Toast stays one layer above the remaining legacy `highPriority` modal path
(`z-1100`) so notifications keep their current visibility without falling
Toast stays one layer above the overlay primitives so notifications remain
visible above dialogs, popovers, and other portalled surfaces without falling
back to `z-9999`.
### Rules
@ -104,7 +103,7 @@ back to `z-9999`.
Once all legacy overlays are removed:
1. Reduce `z-1002` back to `z-50` across all `base/ui/` primitives.
1. Reduce Toast from `z-1101` to `z-51`.
1. Reduce Toast from `z-1003` to `z-51`.
1. Remove this section from the migration guide.
## React Refresh policy for base UI primitives

View File

@ -3097,11 +3097,6 @@
"count": 1
}
},
"app/components/base/modal/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 3
}
},
"app/components/base/modal/modal.stories.tsx": {
"no-console": {
"count": 4
@ -3776,16 +3771,6 @@
"count": 1
}
},
"app/components/base/ui/dialog/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/base/ui/toast/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/base/video-gallery/VideoPlayer.tsx": {
"react/set-state-in-effect": {
"count": 1
@ -5740,11 +5725,6 @@
"count": 1
}
},
"app/components/goto-anything/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"app/components/header/account-about/index.tsx": {
"no-restricted-imports": {
"count": 1
@ -11501,11 +11481,6 @@
"count": 1
}
},
"app/signin/normal-form.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 25
}
},
"app/signin/one-more-step.tsx": {
"no-restricted-imports": {
"count": 2