feat(web): complete Phase 0 guardrails and add Base UI overlay primitives

- Install @base-ui/react 1.2.0
- Define semantic z-index layer tokens (dropdown/popover/modal/toast/tooltip)
- Add TooltipProvider to root layout with global timing config
- Mark portal-to-follow-elem as deprecated with migration guide
- Enforce no-restricted-imports as error with suppression baseline
- Add ESLint rule to block new portal-to-follow-elem usage in business code
- Scaffold Phase 1 semantic primitives: Tooltip, DropdownMenu, Popover, Dialog

Part of #32767
This commit is contained in:
yyh
2026-03-02 13:36:12 +08:00
parent aca3d1900e
commit 095a085fd4
12 changed files with 930 additions and 16 deletions

View File

@ -1,4 +1,16 @@
'use client'
/**
* @deprecated Use semantic overlay primitives from `@/app/components/base/ui/` instead.
* This component will be removed after migration is complete.
* See: https://github.com/langgenius/dify/issues/32767
*
* Migration guide:
* - Tooltip → `@/app/components/base/ui/tooltip`
* - Menu/Dropdown → `@/app/components/base/ui/dropdown-menu`
* - Popover → `@/app/components/base/ui/popover`
* - Dialog/Modal → `@/app/components/base/ui/dialog`
* - Select → `@/app/components/base/ui/select`
*/
import type { OffsetOptions, Placement } from '@floating-ui/react'
import {
autoUpdate,
@ -33,6 +45,7 @@ export type PortalToFollowElemOptions = {
triggerPopupSameWidth?: boolean
}
/** @deprecated Use semantic overlay primitives instead. See #32767. */
export function usePortalToFollowElem({
placement = 'bottom',
open: controlledOpen,
@ -110,6 +123,7 @@ export function usePortalToFollowElemContext() {
return context
}
/** @deprecated Use semantic overlay primitives instead. See #32767. */
export function PortalToFollowElem({
children,
...options
@ -124,6 +138,7 @@ export function PortalToFollowElem({
)
}
/** @deprecated Use semantic overlay primitives instead. See #32767. */
export const PortalToFollowElemTrigger = (
{
ref: propRef,
@ -164,6 +179,7 @@ export const PortalToFollowElemTrigger = (
}
PortalToFollowElemTrigger.displayName = 'PortalToFollowElemTrigger'
/** @deprecated Use semantic overlay primitives instead. See #32767. */
export const PortalToFollowElemContent = (
{
ref: propRef,

View File

@ -0,0 +1,44 @@
'use client'
import { Dialog as BaseDialog } from '@base-ui/react/dialog'
import * as React from 'react'
import { cn } from '@/utils/classnames'
export const Dialog = BaseDialog.Root
export const DialogTrigger = BaseDialog.Trigger
export const DialogTitle = BaseDialog.Title
export const DialogDescription = BaseDialog.Description
export const DialogClose = BaseDialog.Close
type DialogContentProps = {
children: React.ReactNode
className?: string
overlayClassName?: string
}
export function DialogContent({
children,
className,
overlayClassName,
}: DialogContentProps) {
return (
<BaseDialog.Portal>
<BaseDialog.Backdrop
className={cn(
'fixed inset-0 z-modal bg-background-overlay',
'transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0',
overlayClassName,
)}
/>
<BaseDialog.Popup
className={cn(
'fixed left-1/2 top-1/2 z-modal max-h-[80dvh] w-[480px] max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-6 shadow-xl',
'transition-all duration-150 data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0',
className,
)}
>
{children}
</BaseDialog.Popup>
</BaseDialog.Portal>
)
}

View File

@ -0,0 +1,100 @@
'use client'
import type { Placement } from '@floating-ui/react'
import { Menu } from '@base-ui/react/menu'
import * as React from 'react'
import { cn } from '@/utils/classnames'
function parsePlacement(placement: Placement) {
const [side, align] = placement.split('-') as [
'top' | 'bottom' | 'left' | 'right',
'start' | 'center' | 'end' | undefined,
]
return { side, align: align ?? 'center' as const }
}
export const DropdownMenu = Menu.Root
export const DropdownMenuTrigger = Menu.Trigger
export const DropdownMenuGroup = Menu.Group
export const DropdownMenuGroupLabel = Menu.GroupLabel
export const DropdownMenuRadioGroup = Menu.RadioGroup
export const DropdownMenuRadioItem = Menu.RadioItem
export const DropdownMenuRadioItemIndicator = Menu.RadioItemIndicator
export const DropdownMenuCheckboxItem = Menu.CheckboxItem
export const DropdownMenuCheckboxItemIndicator = Menu.CheckboxItemIndicator
type DropdownMenuContentProps = {
children: React.ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
}
export function DropdownMenuContent({
children,
placement = 'bottom-end',
sideOffset = 4,
alignOffset = 0,
className,
popupClassName,
}: DropdownMenuContentProps) {
const { side, align } = parsePlacement(placement)
return (
<Menu.Portal>
<Menu.Positioner
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-dropdown outline-none', className)}
>
<Menu.Popup
className={cn(
'rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg py-1 text-sm text-text-secondary shadow-lg',
'origin-[var(--transform-origin)] transition-[transform,scale,opacity] data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0',
popupClassName,
)}
>
{children}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
)
}
type DropdownMenuItemProps = React.ComponentPropsWithoutRef<typeof Menu.Item> & {
destructive?: boolean
}
export function DropdownMenuItem({
className,
destructive,
...props
}: DropdownMenuItemProps) {
return (
<Menu.Item
className={cn(
'mx-1 flex h-8 cursor-pointer select-none items-center rounded-lg px-3 outline-none',
'data-[highlighted]:bg-components-panel-on-panel-item-bg-hover',
destructive && 'text-text-destructive',
className,
)}
{...props}
/>
)
}
export function DropdownMenuSeparator({
className,
...props
}: React.ComponentPropsWithoutRef<typeof Menu.Separator>) {
return (
<Menu.Separator
className={cn('my-1 h-px bg-divider-regular', className)}
{...props}
/>
)
}

View File

@ -0,0 +1,62 @@
'use client'
import type { Placement } from '@floating-ui/react'
import { Popover as BasePopover } from '@base-ui/react/popover'
import * as React from 'react'
import { cn } from '@/utils/classnames'
function parsePlacement(placement: Placement) {
const [side, align] = placement.split('-') as [
'top' | 'bottom' | 'left' | 'right',
'start' | 'center' | 'end' | undefined,
]
return { side, align: align ?? 'center' as const }
}
export const Popover = BasePopover.Root
export const PopoverTrigger = BasePopover.Trigger
export const PopoverClose = BasePopover.Close
export const PopoverTitle = BasePopover.Title
export const PopoverDescription = BasePopover.Description
type PopoverContentProps = {
children: React.ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
}
export function PopoverContent({
children,
placement = 'bottom',
sideOffset = 8,
alignOffset = 0,
className,
popupClassName,
}: PopoverContentProps) {
const { side, align } = parsePlacement(placement)
return (
<BasePopover.Portal>
<BasePopover.Positioner
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-popover outline-none', className)}
>
<BasePopover.Popup
className={cn(
'rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
'origin-[var(--transform-origin)] transition-[transform,scale,opacity] data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0',
popupClassName,
)}
>
{children}
</BasePopover.Popup>
</BasePopover.Positioner>
</BasePopover.Portal>
)
}

View File

@ -0,0 +1,112 @@
'use client'
import type { Placement } from '@floating-ui/react'
import { Select as BaseSelect } from '@base-ui/react/select'
import * as React from 'react'
import { cn } from '@/utils/classnames'
function parsePlacement(placement: Placement) {
const [side, align] = placement.split('-') as [
'top' | 'bottom' | 'left' | 'right',
'start' | 'center' | 'end' | undefined,
]
return { side, align: align ?? 'center' as const }
}
export const Select = BaseSelect.Root
export const SelectValue = BaseSelect.Value
export const SelectGroup = BaseSelect.Group
export const SelectGroupLabel = BaseSelect.GroupLabel
export const SelectSeparator = BaseSelect.Separator
export function SelectTrigger({
className,
children,
...props
}: React.ComponentPropsWithoutRef<typeof BaseSelect.Trigger>) {
return (
<BaseSelect.Trigger
className={cn(
'group relative flex h-8 w-full items-center rounded-lg border-0 bg-components-input-bg-normal px-2 text-left text-components-input-text-filled outline-none',
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
{children}
<BaseSelect.Icon className="ml-1 shrink-0 text-text-quaternary transition-colors group-hover:text-text-secondary data-[open]:text-text-secondary">
<span className="i-ri-arrow-down-s-line h-4 w-4" />
</BaseSelect.Icon>
</BaseSelect.Trigger>
)
}
type SelectContentProps = {
children: React.ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
listClassName?: string
}
export function SelectContent({
children,
placement = 'bottom-start',
sideOffset = 4,
alignOffset = 0,
className,
popupClassName,
listClassName,
}: SelectContentProps) {
const { side, align } = parsePlacement(placement)
return (
<BaseSelect.Portal>
<BaseSelect.Positioner
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-dropdown outline-none', className)}
>
<BaseSelect.Popup
className={cn(
'rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
'origin-[var(--transform-origin)] transition-[transform,scale,opacity] data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0',
popupClassName,
)}
>
<BaseSelect.List className={cn('max-h-80 min-w-[10rem] overflow-auto p-1 outline-none', listClassName)}>
{children}
</BaseSelect.List>
</BaseSelect.Popup>
</BaseSelect.Positioner>
</BaseSelect.Portal>
)
}
export function SelectItem({
className,
children,
...props
}: React.ComponentPropsWithoutRef<typeof BaseSelect.Item>) {
return (
<BaseSelect.Item
className={cn(
'flex h-8 cursor-pointer items-center rounded-lg px-2 text-text-secondary outline-none system-sm-medium',
'data-[disabled]:cursor-not-allowed data-[highlighted]:bg-state-base-hover data-[disabled]:opacity-50',
className,
)}
{...props}
>
<BaseSelect.ItemText className="mr-1 grow truncate px-1">
{children}
</BaseSelect.ItemText>
<BaseSelect.ItemIndicator className="flex shrink-0 items-center text-text-accent">
<span className="i-ri-check-line h-4 w-4" />
</BaseSelect.ItemIndicator>
</BaseSelect.Item>
)
}

View File

@ -0,0 +1,69 @@
'use client'
import type { Placement } from '@floating-ui/react'
import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip'
import * as React from 'react'
import { cn } from '@/utils/classnames'
function parsePlacement(placement: Placement) {
const [side, align] = placement.split('-') as [
'top' | 'bottom' | 'left' | 'right',
'start' | 'center' | 'end' | undefined,
]
return { side, align: align ?? 'center' as const }
}
export type TooltipProps = {
position?: Placement
disabled?: boolean
popupContent?: React.ReactNode
children?: React.ReactNode
popupClassName?: string
noDecoration?: boolean
offset?: number
}
const Tooltip = React.memo(({
position = 'top',
disabled = false,
popupContent,
children,
popupClassName,
noDecoration,
offset = 8,
}: TooltipProps) => {
const { side, align } = parsePlacement(position)
if (!popupContent || disabled)
return <>{children}</>
return (
<BaseTooltip.Root>
{React.isValidElement(children)
? <BaseTooltip.Trigger render={children} />
: <BaseTooltip.Trigger render={<span className="inline-flex" />}>{children}</BaseTooltip.Trigger>}
<BaseTooltip.Portal>
<BaseTooltip.Positioner
side={side}
align={align}
sideOffset={offset}
className="z-tooltip outline-none"
>
<BaseTooltip.Popup
className={cn(
!noDecoration && 'max-w-[300px] break-words rounded-md bg-components-panel-bg px-3 py-2 text-left text-text-tertiary shadow-lg system-xs-regular',
popupClassName,
)}
>
{popupContent}
</BaseTooltip.Popup>
</BaseTooltip.Positioner>
</BaseTooltip.Portal>
</BaseTooltip.Root>
)
})
Tooltip.displayName = 'Tooltip'
export const TooltipProvider = BaseTooltip.Provider
export default Tooltip

View File

@ -9,6 +9,7 @@ import { getDatasetMap } from '@/env'
import { getLocaleOnServer } from '@/i18n-config/server'
import { cn } from '@/utils/classnames'
import { ToastProvider } from './components/base/toast'
import { TooltipProvider } from './components/base/ui/tooltip'
import BrowserInitializer from './components/browser-initializer'
import { ReactScanLoader } from './components/devtools/react-scan/loader'
import { I18nServerProvider } from './components/provider/i18n-server'
@ -78,7 +79,9 @@ const LocaleLayout = async ({
<I18nServerProvider>
<ToastProvider>
<GlobalPublicStoreProvider>
{children}
<TooltipProvider delay={300} closeDelay={200}>
{children}
</TooltipProvider>
</GlobalPublicStoreProvider>
</ToastProvider>
</I18nServerProvider>

File diff suppressed because it is too large Load Diff

View File

@ -145,4 +145,34 @@ export default antfu(
'hyoban/no-dependency-version-prefix': 'error',
},
},
{
name: 'dify/base-ui-primitives',
files: ['app/components/base/ui/**/*.ts', 'app/components/base/ui/**/*.tsx'],
rules: {
'react-refresh/only-export-components': 'off',
},
},
{
name: 'dify/overlay-migration',
files: [GLOB_TS, GLOB_TSX],
ignores: [
'app/components/base/**',
...GLOB_TESTS,
],
rules: {
'no-restricted-imports': ['error', {
paths: [{
name: '@/app/components/base/portal-to-follow-elem',
message: 'Deprecated: use semantic overlay primitives from @/app/components/base/ui/ instead. See issue #32767.',
}],
patterns: [{
group: [
'**/portal-to-follow-elem',
'**/portal-to-follow-elem/index',
],
message: 'Deprecated: use semantic overlay primitives from @/app/components/base/ui/ instead. See issue #32767.',
}],
}],
},
},
)

View File

@ -63,6 +63,7 @@
"dependencies": {
"@amplitude/analytics-browser": "2.33.1",
"@amplitude/plugin-session-replay-browser": "1.23.6",
"@base-ui/react": "1.2.0",
"@emoji-mart/data": "1.2.1",
"@floating-ui/react": "0.26.28",
"@formatjs/intl-localematcher": "0.5.10",

53
web/pnpm-lock.yaml generated
View File

@ -60,6 +60,9 @@ importers:
'@amplitude/plugin-session-replay-browser':
specifier: 1.23.6
version: 1.23.6(@amplitude/rrweb@2.0.0-alpha.35)(rollup@4.56.0)
'@base-ui/react':
specifier: 1.2.0
version: 1.2.0(@types/react@19.2.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@emoji-mart/data':
specifier: 1.2.1
version: 1.2.1
@ -900,6 +903,27 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@base-ui/react@1.2.0':
resolution: {integrity: sha512-O6aEQHcm+QyGTFY28xuwRD3SEJGZOBDpyjN2WvpfWYFVhg+3zfXPysAILqtM0C1kWC82MccOE/v1j+GHXE4qIw==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@types/react': ^17 || ^18 || ^19
react: ^17 || ^18 || ^19
react-dom: ^17 || ^18 || ^19
peerDependenciesMeta:
'@types/react':
optional: true
'@base-ui/utils@0.2.5':
resolution: {integrity: sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw==}
peerDependencies:
'@types/react': ^17 || ^18 || ^19
react: ^17 || ^18 || ^19
react-dom: ^17 || ^18 || ^19
peerDependenciesMeta:
'@types/react':
optional: true
'@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
@ -6838,6 +6862,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
reserved-identifiers@1.2.0:
resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==}
engines: {node: '>=18'}
@ -8336,6 +8363,30 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@base-ui/react@1.2.0(@types/react@19.2.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@base-ui/utils': 0.2.5(@types/react@19.2.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@floating-ui/utils': 0.2.10
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
tabbable: 6.4.0
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.9
'@base-ui/utils@0.2.5(@types/react@19.2.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@floating-ui/utils': 0.2.10
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
reselect: 5.1.1
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.9
'@bcoe/v8-coverage@1.0.2': {}
'@braintree/sanitize-url@7.1.1': {}
@ -15158,6 +15209,8 @@ snapshots:
require-from-string@2.0.2: {}
reselect@5.1.1: {}
reserved-identifiers@1.2.0: {}
resize-observer-polyfill@1.5.1: {}

View File

@ -161,6 +161,13 @@ const config = {
'progress-bar-indeterminate-stripe': 'var(--color-progress-bar-indeterminate-stripe)',
'chat-answer-human-input-form-divider-bg': 'var(--color-chat-answer-human-input-form-divider-bg)',
},
zIndex: {
dropdown: '1000',
popover: '1100',
modal: '1200',
toast: '1300',
tooltip: '1400',
},
animation: {
'spin-slow': 'spin 2s linear infinite',
},