Compare commits

...

48 Commits

Author SHA1 Message Date
yyh
ec70e7c82f fix: update tests 2026-03-03 17:01:17 +08:00
yyh
2c609000ec Merge remote-tracking branch 'origin/feat/web-overlay-phase0-primitives' into test/ui-primitive-wrapper-tests-pr 2026-03-03 16:48:51 +08:00
yyh
109cae8692 fix(dropdown-menu): align menu item padding with main branch and use DropdownMenuItem for theme row
Reduce menuRowBaseClassName padding from px-3 to px-2 to match main branch spacing.
Replace raw div with DropdownMenuItem for theme row to fix icon alignment and ARIA semantics.
2026-03-03 16:42:44 +08:00
yyh
959121aa0b fix 2026-03-03 16:09:31 +08:00
yyh
156d9d8de2 fix: add aria-label 2026-03-03 15:57:56 +08:00
yyh
62f6350b99 Merge remote-tracking branch 'origin/main' into feat/web-overlay-phase0-primitives 2026-03-03 15:50:57 +08:00
yyh
4c8877044c feat(ui): fill capability gaps in tooltip, dialog, and select primitives
Tooltip: add enter/exit opacity animation with data-[instant] support
Dialog: add overflow-y-auto for long content, optional closable button
Select: add clearable/loading props to trigger, disable alignItemWithTrigger
2026-03-03 15:49:29 +08:00
yyh
8234bced70 fix: lint config 2026-03-03 15:21:56 +08:00
yyh
174e95cb41 chore: deprecate base/tooltip, base/modal, base/select and restrict imports via eslint
- Add @deprecated JSDoc to base/tooltip, base/modal, base/select
- Add no-restricted-imports patterns for all three in eslint overlay-migration rule
- Regenerate eslint-suppressions.json with bulk-suppressed violations
- Add --pass-on-unpruned-suppressions to lint-staged eslint command
2026-03-03 15:16:19 +08:00
yyh
933e173ac8 refactor(dropdown-menu): wrap Radio/Checkbox items with shared styles and sealed indicators
- Extract menuRowBaseClassName and menuRowStateClassName constants shared across all row components
- Wrap RadioItem and CheckboxItem with default styles matching DropdownMenuItem
- Wrap RadioItemIndicator and CheckboxItemIndicator with hardcoded check icon, Omit children to prevent misuse
- Refactor DropdownMenuItem and SubTrigger to use shared className constants
2026-03-03 14:53:54 +08:00
yyh
a32ab27ce0 refactor(dropdown-menu): improve primitive defaults and deduplicate account-dropdown
- Add overflow handling (max-h-[var(--available-height)]) to Popup
- Add disabled styles (cursor-not-allowed, opacity-50) to Item and SubTrigger
- Change hover token to bg-state-base-hover for consistency
- Build arrow icon into SubTrigger so callers don't repeat it
- Style DropdownMenuGroupLabel with default typography
- Extract shared MenuItemContent and ExternalLinkIndicator into menu-item-content.tsx
- Remove duplicated className constants and component definitions across account-dropdown files
- Remove !important overrides from callers now that primitive defaults are correct
- Remove manual max-h-[70vh] from SubContent (handled by primitive)
2026-03-03 14:45:02 +08:00
yyh
2dfd7f4c65 fix: docs 2026-03-03 14:03:55 +08:00
yyh
bda226c18e Merge remote-tracking branch 'origin/main' into feat/web-overlay-phase0-primitives
# Conflicts:
#	web/eslint-suppressions.json
2026-03-03 13:56:46 +08:00
yyh
0664b21557 trigger ci
Signed-off-by: yyh <yuanyouhuilyz@gmail.com>
2026-03-03 13:22:26 +08:00
yyh
7d64082c6f fix: lint 2026-03-03 13:09:47 +08:00
yyh
aa7a6e96ed fix: select 2026-03-03 13:05:01 +08:00
yyh
d6aac66c25 fix 2026-03-03 12:53:31 +08:00
yyh
c761e737f5 Merge branch 'main' into feat/web-overlay-phase0-primitives 2026-03-03 11:56:03 +08:00
yyh
a41f4aa982 refactor(web): refactor account menu component with Base UI primitives (#32854) 2026-03-03 10:45:22 +08:00
yyh
bf785e8df0 fix(web): unify overlay z-index, decouple Placement type, and improve animation a11y
- Add z-50 to all overlay Positioners so overlays inside a Dialog
  (e.g. Tooltip on a dialog button) are not clipped by its backdrop
- Replace @floating-ui/react Placement import with self-owned type
  definition to remove the transitive type dependency
- Change Dialog popup transition-all to explicit transition-[transform,scale,opacity]
  to avoid animating unintended CSS properties
- Add motion-reduce:transition-none to all animated overlay elements
  for prefers-reduced-motion compliance
2026-03-02 22:30:46 +08:00
yyh
74f96d54ca test(web): cover passthrough props in base ui wrappers 2026-03-02 20:18:30 +08:00
yyh
ceb8c8bf1e Merge branch 'feat/web-overlay-phase0-primitives' into test/ui-primitive-wrapper-tests-pr 2026-03-02 20:10:15 +08:00
yyh
4562a11903 refactor(web): align overlay primitive content prop pass-through
- add positioner/popup/list pass-through props for select/popover/dropdown wrappers

- keep existing semantic placement API while enabling Base UI behavioral extensibility

- align wrapper ergonomics across overlay primitives
2026-03-02 20:08:09 +08:00
autofix-ci[bot]
a9266fb7ed [autofix.ci] apply automated fixes 2026-03-02 11:58:46 +00:00
yyh
52d02b132e feat(web): tighten overlay migration lint governance
- narrow overlay-migration ignore scope to explicit legacy base file allowlist

- replace directory-level react-refresh disable with allowExportNames for base UI primitives

- extract long lint constants into eslint.constants.mjs for config readability

- add overlay migration guide and link it from lint docs

- refactor dropdown-menu internal popup helper to avoid react-refresh false positives
2026-03-02 19:56:15 +08:00
yyh
78e6d0b88a Merge branch 'feat/web-overlay-phase0-primitives' into test/ui-primitive-wrapper-tests-pr 2026-03-02 19:35:38 +08:00
yyh
967f8caecd fix: lint 2026-03-02 19:33:47 +08:00
yyh
7e8f22a85a Merge branch 'main' into feat/web-overlay-phase0-primitives 2026-03-02 19:28:41 +08:00
yyh
5d9796b861 test(web): add unit tests for base ui primitive wrappers 2026-03-02 19:20:40 +08:00
yyh
6e7103f6d3 Revert "test(web): add unit tests for base ui primitive wrappers"
This reverts commit 03180ffc2c.
2026-03-02 19:20:40 +08:00
yyh
03180ffc2c test(web): add unit tests for base ui primitive wrappers 2026-03-02 19:17:29 +08:00
yyh
afcd3b81ce lint 2026-03-02 19:00:17 +08:00
yyh
3385a41075 chore(web): drop residual account menu formatting deltas 2026-03-02 18:54:27 +08:00
yyh
e358ca9a12 chore(web): keep phase0 branch focused on base ui primitives 2026-03-02 18:52:43 +08:00
yyh
3ffb87b044 Merge branch 'main' into feat/web-overlay-phase0-primitives 2026-03-02 18:22:21 +08:00
yyh
f5e32e533b fix(ui-tooltip): prevent popup className override 2026-03-02 18:21:56 +08:00
yyh
4b3dceeda1 fix 2026-03-02 18:07:57 +08:00
yyh
c4fe93a8b8 refactor(ui): compose tooltip primitives and dedupe menu popup 2026-03-02 18:03:25 +08:00
yyh
f83f84afac refactor(tooltip): support per-instance delay and apply in compliance menu 2026-03-02 17:54:59 +08:00
yyh
5c278d99d4 refactor(header): align account submenu contract and tests 2026-03-02 17:41:20 +08:00
yyh
80f86afbca fix: tests and named import 2026-03-02 15:21:53 +08:00
yyh
3377240c3b refactor(web): align account dropdown submenu semantics 2026-03-02 15:18:32 +08:00
yyh
22ce39cd0e Merge remote-tracking branch 'origin/main' into feat/web-overlay-phase0-primitives 2026-03-02 15:10:09 +08:00
yyh
dde6f82e9e fix 2026-03-02 15:09:44 +08:00
yyh
3d7872bdcf refactor(web): simplify z-index to rely on isolation + DOM order
Remove z-index tokens from tailwind config. With root `isolation: isolate`,
portaled overlays naturally sit above app content via DOM order.

- Tooltip / Popover / Dropdown: no z-index needed
- Dialog: z-50 (modal must cover non-modal portals)
- Toast: z-[99] (always on top, defined in toast component)
2026-03-02 15:01:08 +08:00
yyh
f65159bd00 refactor(web): share placement parser across base ui primitives 2026-03-02 14:57:06 +08:00
yyh
6b55e50106 refactor(web): migrate account dropdown to dropdown primitives 2026-03-02 14:50:04 +08:00
yyh
095a085fd4 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
2026-03-02 13:36:12 +08:00
31 changed files with 4207 additions and 423 deletions

View File

@@ -1,3 +1,8 @@
/**
* @deprecated Use `@/app/components/base/ui/dialog` instead.
* This component will be removed after migration is complete.
* See: https://github.com/langgenius/dify/issues/32767
*/
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react' import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'
import { noop } from 'es-toolkit/function' import { noop } from 'es-toolkit/function'
import { Fragment } from 'react' import { Fragment } from 'react'

View File

@@ -1,3 +1,8 @@
/**
* @deprecated Use `@/app/components/base/ui/dialog` instead.
* This component will be removed after migration is complete.
* See: https://github.com/langgenius/dify/issues/32767
*/
import type { ButtonProps } from '@/app/components/base/button' import type { ButtonProps } from '@/app/components/base/button'
import { noop } from 'es-toolkit/function' import { noop } from 'es-toolkit/function'
import { memo } from 'react' import { memo } from 'react'

View File

@@ -1,4 +1,16 @@
'use client' '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 type { OffsetOptions, Placement } from '@floating-ui/react'
import { import {
autoUpdate, autoUpdate,
@@ -33,6 +45,7 @@ export type PortalToFollowElemOptions = {
triggerPopupSameWidth?: boolean triggerPopupSameWidth?: boolean
} }
/** @deprecated Use semantic overlay primitives instead. See #32767. */
export function usePortalToFollowElem({ export function usePortalToFollowElem({
placement = 'bottom', placement = 'bottom',
open: controlledOpen, open: controlledOpen,
@@ -110,6 +123,7 @@ export function usePortalToFollowElemContext() {
return context return context
} }
/** @deprecated Use semantic overlay primitives instead. See #32767. */
export function PortalToFollowElem({ export function PortalToFollowElem({
children, children,
...options ...options
@@ -124,6 +138,7 @@ export function PortalToFollowElem({
) )
} }
/** @deprecated Use semantic overlay primitives instead. See #32767. */
export const PortalToFollowElemTrigger = ( export const PortalToFollowElemTrigger = (
{ {
ref: propRef, ref: propRef,
@@ -164,6 +179,7 @@ export const PortalToFollowElemTrigger = (
} }
PortalToFollowElemTrigger.displayName = 'PortalToFollowElemTrigger' PortalToFollowElemTrigger.displayName = 'PortalToFollowElemTrigger'
/** @deprecated Use semantic overlay primitives instead. See #32767. */
export const PortalToFollowElemContent = ( export const PortalToFollowElemContent = (
{ {
ref: propRef, ref: propRef,

View File

@@ -1,4 +1,9 @@
'use client' 'use client'
/**
* @deprecated Use `@/app/components/base/ui/select` instead.
* This component will be removed after migration is complete.
* See: https://github.com/langgenius/dify/issues/32767
*/
import type { FC } from 'react' import type { FC } from 'react'
import { Combobox, ComboboxButton, ComboboxInput, ComboboxOption, ComboboxOptions, Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react' import { Combobox, ComboboxButton, ComboboxInput, ComboboxOption, ComboboxOptions, Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'
import { ChevronDownIcon, ChevronUpIcon, XMarkIcon } from '@heroicons/react/20/solid' import { ChevronDownIcon, ChevronUpIcon, XMarkIcon } from '@heroicons/react/20/solid'
@@ -236,7 +241,7 @@ const SimpleSelect: FC<ISelectProps> = ({
}} }}
className={cn(`flex h-full w-full items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`, className)} className={cn(`flex h-full w-full items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`, className)}
> >
<span className={cn('system-sm-regular block truncate text-left text-components-input-text-filled', !selectedItem?.name && 'text-components-input-text-placeholder')}>{selectedItem?.name ?? localPlaceholder}</span> <span className={cn('block truncate text-left text-components-input-text-filled system-sm-regular', !selectedItem?.name && 'text-components-input-text-placeholder')}>{selectedItem?.name ?? localPlaceholder}</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2"> <span className="absolute inset-y-0 right-0 flex items-center pr-2">
{isLoading {isLoading
? <RiLoader4Line className="h-3.5 w-3.5 animate-spin text-text-secondary" /> ? <RiLoader4Line className="h-3.5 w-3.5 animate-spin text-text-secondary" />

View File

@@ -1,4 +1,9 @@
'use client' 'use client'
/**
* @deprecated Use `@/app/components/base/ui/tooltip` instead.
* This component will be removed after migration is complete.
* See: https://github.com/langgenius/dify/issues/32767
*/
import type { OffsetOptions, Placement } from '@floating-ui/react' import type { OffsetOptions, Placement } from '@floating-ui/react'
import type { FC } from 'react' import type { FC } from 'react'
import { RiQuestionLine } from '@remixicon/react' import { RiQuestionLine } from '@remixicon/react'
@@ -130,7 +135,7 @@ const Tooltip: FC<TooltipProps> = ({
{!!popupContent && ( {!!popupContent && (
<div <div
className={cn( className={cn(
!noDecoration && 'system-xs-regular relative max-w-[300px] break-words rounded-md bg-components-panel-bg px-3 py-2 text-left text-text-tertiary shadow-lg', !noDecoration && 'relative 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, popupClassName,
)} )}
onMouseEnter={() => { onMouseEnter={() => {

View File

@@ -0,0 +1,143 @@
import type { ComponentPropsWithoutRef } from 'react'
import { Dialog as BaseDialog } from '@base-ui/react/dialog'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
} from '../index'
type DivPrimitiveProps = ComponentPropsWithoutRef<'div'>
type ButtonPrimitiveProps = ComponentPropsWithoutRef<'button'>
type SectionPrimitiveProps = ComponentPropsWithoutRef<'section'>
vi.mock('@base-ui/react/dialog', () => {
const createDivPrimitive = () => {
return vi.fn(({ children, ...props }: DivPrimitiveProps) => (
<div {...props}>
{children}
</div>
))
}
const createButtonPrimitive = () => {
return vi.fn(({ children, ...props }: ButtonPrimitiveProps) => (
<button type="button" {...props}>
{children}
</button>
))
}
const createPopupPrimitive = () => {
return vi.fn(({ children, ...props }: SectionPrimitiveProps) => (
<section role="dialog" {...props}>
{children}
</section>
))
}
return {
Dialog: {
Root: createDivPrimitive(),
Trigger: createDivPrimitive(),
Title: createDivPrimitive(),
Description: createDivPrimitive(),
Close: createButtonPrimitive(),
Portal: createDivPrimitive(),
Backdrop: createDivPrimitive(),
Popup: createPopupPrimitive(),
},
}
})
describe('Dialog wrapper', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering behavior for wrapper-specific structure and content.
describe('Rendering', () => {
it('should render portal structure and dialog content when DialogContent is rendered', () => {
// Arrange
const contentText = 'dialog body'
// Act
render(
<DialogContent>
<span>{contentText}</span>
</DialogContent>,
)
// Assert
expect(vi.mocked(BaseDialog.Portal)).toHaveBeenCalledTimes(1)
expect(vi.mocked(BaseDialog.Backdrop)).toHaveBeenCalledTimes(1)
expect(vi.mocked(BaseDialog.Popup)).toHaveBeenCalledTimes(1)
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByText(contentText)).toBeInTheDocument()
})
})
// Props behavior for closable semantics and defaults.
describe('Props', () => {
it('should not render close button when closable is omitted', () => {
// Arrange
render(
<DialogContent>
<span>content</span>
</DialogContent>,
)
// Assert
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument()
})
it('should not render close button when closable is false', () => {
// Arrange
render(
<DialogContent closable={false}>
<span>content</span>
</DialogContent>,
)
// Assert
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument()
})
it('should render semantic close button when closable is true', () => {
// Arrange
render(
<DialogContent closable>
<span>content</span>
</DialogContent>,
)
// Assert
const closeButton = screen.getByRole('button', { name: 'Close' })
expect(closeButton).toHaveAttribute('aria-label', 'Close')
expect(screen.getByRole('dialog')).toContainElement(closeButton)
const closeIcon = closeButton.querySelector('span')
expect(closeIcon).toBeInTheDocument()
expect(closeButton).toContainElement(closeIcon)
})
})
// Export mapping ensures wrapper aliases point to base primitives.
describe('Exports', () => {
it('should map dialog aliases to the matching base dialog primitives', () => {
// Arrange
const basePrimitives = BaseDialog
// Act & Assert
expect(Dialog).toBe(basePrimitives.Root)
expect(DialogTrigger).toBe(basePrimitives.Trigger)
expect(DialogTitle).toBe(basePrimitives.Title)
expect(DialogDescription).toBe(basePrimitives.Description)
expect(DialogClose).toBe(basePrimitives.Close)
})
})
})

View File

@@ -0,0 +1,58 @@
'use client'
// z-index strategy (relies on root `isolation: isolate` in layout.tsx):
// All overlay primitives (Tooltip / Popover / Dropdown / Select / Dialog) — z-50
// 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.
// Toast — z-[99], always on top (defined in toast component)
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
closable?: boolean
}
export function DialogContent({
children,
className,
overlayClassName,
closable = false,
}: DialogContentProps) {
return (
<BaseDialog.Portal>
<BaseDialog.Backdrop
className={cn(
'fixed inset-0 z-50 bg-background-overlay',
'transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
overlayClassName,
)}
/>
<BaseDialog.Popup
className={cn(
'fixed left-1/2 top-1/2 z-50 max-h-[80dvh] w-[480px] max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-6 shadow-xl',
'transition-[transform,scale,opacity] duration-150 data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
className,
)}
>
{closable && (
<BaseDialog.Close aria-label="Close" className="absolute right-6 top-6 z-10 flex h-5 w-5 cursor-pointer items-center justify-center rounded-2xl hover:bg-state-base-hover">
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
</BaseDialog.Close>
)}
{children}
</BaseDialog.Popup>
</BaseDialog.Portal>
)
}

View File

@@ -0,0 +1,407 @@
import type { Placement } from '@/app/components/base/ui/placement'
import { Menu } from '@base-ui/react/menu'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { parsePlacement } from '@/app/components/base/ui/placement'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '../index'
vi.mock('@base-ui/react/menu', async () => {
const React = await import('react')
type PrimitiveProps = React.HTMLAttributes<HTMLDivElement> & {
children?: React.ReactNode
}
type PrimitiveOptions = {
displayName: string
defaultRole?: React.AriaRole
}
type PositionerProps = PrimitiveProps & {
side?: string
align?: string
sideOffset?: number
alignOffset?: number
}
const createPrimitive = ({ displayName, defaultRole }: PrimitiveOptions) => {
const Primitive = React.forwardRef<HTMLDivElement, PrimitiveProps>(({ children, role, ...props }, ref) => {
return React.createElement(
'div',
{
ref,
role: role ?? defaultRole,
...props,
},
children,
)
})
Primitive.displayName = displayName
return Primitive
}
const Portal = ({ children }: PrimitiveProps) => {
return React.createElement(React.Fragment, null, children)
}
Portal.displayName = 'menu-portal'
const Positioner = React.forwardRef<HTMLDivElement, PositionerProps>(({ children, role, side, align, sideOffset, alignOffset, ...props }, ref) => {
return React.createElement(
'div',
{
ref,
'role': role ?? 'group',
'data-side': side,
'data-align': align,
'data-side-offset': sideOffset,
'data-align-offset': alignOffset,
...props,
},
children,
)
})
Positioner.displayName = 'menu-positioner'
const Menu = {
Root: createPrimitive({ displayName: 'menu-root' }),
Portal,
Trigger: createPrimitive({ displayName: 'menu-trigger', defaultRole: 'button' }),
SubmenuRoot: createPrimitive({ displayName: 'menu-submenu-root' }),
Group: createPrimitive({ displayName: 'menu-group', defaultRole: 'group' }),
GroupLabel: createPrimitive({ displayName: 'menu-group-label' }),
RadioGroup: createPrimitive({ displayName: 'menu-radio-group', defaultRole: 'radiogroup' }),
RadioItem: createPrimitive({ displayName: 'menu-radio-item', defaultRole: 'menuitemradio' }),
RadioItemIndicator: createPrimitive({ displayName: 'menu-radio-item-indicator' }),
CheckboxItem: createPrimitive({ displayName: 'menu-checkbox-item', defaultRole: 'menuitemcheckbox' }),
CheckboxItemIndicator: createPrimitive({ displayName: 'menu-checkbox-item-indicator' }),
Positioner,
Popup: createPrimitive({ displayName: 'menu-popup', defaultRole: 'menu' }),
SubmenuTrigger: createPrimitive({ displayName: 'menu-submenu-trigger', defaultRole: 'menuitem' }),
Item: createPrimitive({ displayName: 'menu-item', defaultRole: 'menuitem' }),
Separator: createPrimitive({ displayName: 'menu-separator', defaultRole: 'separator' }),
}
return { Menu }
})
vi.mock('@/app/components/base/ui/placement', () => ({
parsePlacement: vi.fn((placement: Placement) => {
const [side, align] = placement.split('-') as [string, string | undefined]
return {
side: side as 'top' | 'right' | 'bottom' | 'left',
align: (align ?? 'center') as 'start' | 'center' | 'end',
}
}),
}))
describe('dropdown-menu wrapper', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Ensures exported aliases stay aligned with the wrapped Menu primitives.
describe('alias exports', () => {
it('should map direct aliases to the corresponding Menu primitive when importing menu roots', () => {
// Arrange
// Act
// Assert
expect(DropdownMenu).toBe(Menu.Root)
expect(DropdownMenuPortal).toBe(Menu.Portal)
expect(DropdownMenuTrigger).toBe(Menu.Trigger)
expect(DropdownMenuSub).toBe(Menu.SubmenuRoot)
expect(DropdownMenuGroup).toBe(Menu.Group)
expect(DropdownMenuRadioGroup).toBe(Menu.RadioGroup)
})
})
// Verifies content popup placement and passthrough behavior.
describe('DropdownMenuContent', () => {
it('should position content at bottom-end with default offsets when placement props are omitted', () => {
// Arrange
const parsePlacementMock = vi.mocked(parsePlacement)
// Act
render(
<DropdownMenuContent>
<button type="button">content action</button>
</DropdownMenuContent>,
)
// Assert
const popup = screen.getByRole('menu')
const positioner = popup.parentElement
expect(parsePlacementMock).toHaveBeenCalledTimes(1)
expect(parsePlacementMock).toHaveBeenCalledWith('bottom-end')
expect(positioner).not.toBeNull()
expect(positioner).toHaveAttribute('data-side', 'bottom')
expect(positioner).toHaveAttribute('data-align', 'end')
expect(positioner).toHaveAttribute('data-side-offset', '4')
expect(positioner).toHaveAttribute('data-align-offset', '0')
expect(within(popup).getByRole('button', { name: 'content action' })).toBeInTheDocument()
})
it('should apply custom placement offsets when custom positioning props are provided', () => {
// Arrange
const parsePlacementMock = vi.mocked(parsePlacement)
// Act
render(
<DropdownMenuContent
placement="top-start"
sideOffset={12}
alignOffset={-3}
>
<span>custom content</span>
</DropdownMenuContent>,
)
// Assert
const popup = screen.getByRole('menu')
const positioner = popup.parentElement
expect(parsePlacementMock).toHaveBeenCalledTimes(1)
expect(parsePlacementMock).toHaveBeenCalledWith('top-start')
expect(positioner).not.toBeNull()
expect(positioner).toHaveAttribute('data-side', 'top')
expect(positioner).toHaveAttribute('data-align', 'start')
expect(positioner).toHaveAttribute('data-side-offset', '12')
expect(positioner).toHaveAttribute('data-align-offset', '-3')
expect(within(popup).getByText('custom content')).toBeInTheDocument()
})
it('should forward passthrough attributes and handlers when positionerProps and popupProps are provided', () => {
// Arrange
const handlePositionerMouseEnter = vi.fn()
const handlePopupClick = vi.fn()
// Act
render(
<DropdownMenuContent
positionerProps={{
'aria-label': 'dropdown content positioner',
'id': 'dropdown-content-positioner',
'onMouseEnter': handlePositionerMouseEnter,
}}
popupProps={{
'aria-label': 'dropdown content popup',
'id': 'dropdown-content-popup',
'onClick': handlePopupClick,
}}
>
<span>passthrough content</span>
</DropdownMenuContent>,
)
// Assert
const positioner = screen.getByRole('group', { name: 'dropdown content positioner' })
const popup = screen.getByRole('menu', { name: 'dropdown content popup' })
fireEvent.mouseEnter(positioner)
fireEvent.click(popup)
expect(positioner).toHaveAttribute('id', 'dropdown-content-positioner')
expect(popup).toHaveAttribute('id', 'dropdown-content-popup')
expect(handlePositionerMouseEnter).toHaveBeenCalledTimes(1)
expect(handlePopupClick).toHaveBeenCalledTimes(1)
})
})
// Verifies submenu popup placement and passthrough behavior.
describe('DropdownMenuSubContent', () => {
it('should position sub-content at left-start with default offsets when props are omitted', () => {
// Arrange
const parsePlacementMock = vi.mocked(parsePlacement)
// Act
render(
<DropdownMenuSubContent>
<button type="button">sub action</button>
</DropdownMenuSubContent>,
)
// Assert
const popup = screen.getByRole('menu')
const positioner = popup.parentElement
expect(parsePlacementMock).toHaveBeenCalledTimes(1)
expect(parsePlacementMock).toHaveBeenCalledWith('left-start')
expect(positioner).not.toBeNull()
expect(positioner).toHaveAttribute('data-side', 'left')
expect(positioner).toHaveAttribute('data-align', 'start')
expect(positioner).toHaveAttribute('data-side-offset', '4')
expect(positioner).toHaveAttribute('data-align-offset', '0')
expect(within(popup).getByRole('button', { name: 'sub action' })).toBeInTheDocument()
})
it('should apply custom placement offsets and forward passthrough props when custom sub-content props are provided', () => {
// Arrange
const parsePlacementMock = vi.mocked(parsePlacement)
const handlePositionerFocus = vi.fn()
const handlePopupClick = vi.fn()
// Act
render(
<DropdownMenuSubContent
placement="right-end"
sideOffset={6}
alignOffset={2}
positionerProps={{
'aria-label': 'dropdown sub positioner',
'id': 'dropdown-sub-positioner',
'onFocus': handlePositionerFocus,
}}
popupProps={{
'aria-label': 'dropdown sub popup',
'id': 'dropdown-sub-popup',
'onClick': handlePopupClick,
}}
>
<span>custom sub content</span>
</DropdownMenuSubContent>,
)
// Assert
const positioner = screen.getByRole('group', { name: 'dropdown sub positioner' })
const popup = screen.getByRole('menu', { name: 'dropdown sub popup' })
fireEvent.focus(positioner)
fireEvent.click(popup)
expect(parsePlacementMock).toHaveBeenCalledTimes(1)
expect(parsePlacementMock).toHaveBeenCalledWith('right-end')
expect(positioner).toHaveAttribute('data-side', 'right')
expect(positioner).toHaveAttribute('data-align', 'end')
expect(positioner).toHaveAttribute('data-side-offset', '6')
expect(positioner).toHaveAttribute('data-align-offset', '2')
expect(positioner).toHaveAttribute('id', 'dropdown-sub-positioner')
expect(popup).toHaveAttribute('id', 'dropdown-sub-popup')
expect(handlePositionerFocus).toHaveBeenCalledTimes(1)
expect(handlePopupClick).toHaveBeenCalledTimes(1)
})
})
// Covers submenu trigger behavior with and without destructive flag.
describe('DropdownMenuSubTrigger', () => {
it('should render label and submenu chevron when trigger children are provided', () => {
// Arrange
// Act
render(
<DropdownMenuSubTrigger>
Trigger item
</DropdownMenuSubTrigger>,
)
// Assert
const subTrigger = screen.getByRole('menuitem', { name: 'Trigger item' })
expect(subTrigger.querySelector('span[aria-hidden="true"]')).not.toBeNull()
})
it.each([true, false])('should remain interactive and not leak destructive prop when destructive is %s', (destructive) => {
// Arrange
const handleClick = vi.fn()
// Act
render(
<DropdownMenuSubTrigger
destructive={destructive}
aria-label="submenu action"
id={`submenu-trigger-${String(destructive)}`}
onClick={handleClick}
>
Trigger item
</DropdownMenuSubTrigger>,
)
// Assert
const subTrigger = screen.getByRole('menuitem', { name: 'submenu action' })
fireEvent.click(subTrigger)
expect(subTrigger).toHaveAttribute('id', `submenu-trigger-${String(destructive)}`)
expect(subTrigger).not.toHaveAttribute('destructive')
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
// Covers menu item behavior with and without destructive flag.
describe('DropdownMenuItem', () => {
it.each([true, false])('should remain interactive and not leak destructive prop when destructive is %s', (destructive) => {
// Arrange
const handleClick = vi.fn()
// Act
render(
<DropdownMenuItem
destructive={destructive}
aria-label="menu action"
id={`menu-item-${String(destructive)}`}
onClick={handleClick}
>
Item label
</DropdownMenuItem>,
)
// Assert
const item = screen.getByRole('menuitem', { name: 'menu action' })
fireEvent.click(item)
expect(item).toHaveAttribute('id', `menu-item-${String(destructive)}`)
expect(item).not.toHaveAttribute('destructive')
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
// Verifies separator semantics and row separation behavior.
describe('DropdownMenuSeparator', () => {
it('should forward passthrough props and handlers when separator props are provided', () => {
// Arrange
const handleMouseEnter = vi.fn()
// Act
render(
<DropdownMenuSeparator
aria-label="actions divider"
id="menu-separator"
onMouseEnter={handleMouseEnter}
/>,
)
// Assert
const separator = screen.getByRole('separator', { name: 'actions divider' })
fireEvent.mouseEnter(separator)
expect(separator).toHaveAttribute('id', 'menu-separator')
expect(handleMouseEnter).toHaveBeenCalledTimes(1)
})
it('should keep surrounding menu rows rendered when separator is placed between items', () => {
// Arrange
// Act
render(
<>
<DropdownMenuItem>First action</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Second action</DropdownMenuItem>
</>,
)
// Assert
expect(screen.getByRole('menuitem', { name: 'First action' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'Second action' })).toBeInTheDocument()
expect(screen.getAllByRole('separator')).toHaveLength(1)
})
})
})

View File

@@ -0,0 +1,277 @@
'use client'
import type { Placement } from '@/app/components/base/ui/placement'
import { Menu } from '@base-ui/react/menu'
import * as React from 'react'
import { parsePlacement } from '@/app/components/base/ui/placement'
import { cn } from '@/utils/classnames'
export const DropdownMenu = Menu.Root
export const DropdownMenuPortal = Menu.Portal
export const DropdownMenuTrigger = Menu.Trigger
export const DropdownMenuSub = Menu.SubmenuRoot
export const DropdownMenuGroup = Menu.Group
export const DropdownMenuRadioGroup = Menu.RadioGroup
const menuRowBaseClassName = 'mx-1 flex h-8 cursor-pointer select-none items-center rounded-lg px-2 outline-none'
const menuRowStateClassName = 'data-[highlighted]:bg-state-base-hover data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50'
export function DropdownMenuRadioItem({
className,
...props
}: React.ComponentPropsWithoutRef<typeof Menu.RadioItem>) {
return (
<Menu.RadioItem
className={cn(
menuRowBaseClassName,
menuRowStateClassName,
className,
)}
{...props}
/>
)
}
export function DropdownMenuRadioItemIndicator({
className,
...props
}: Omit<React.ComponentPropsWithoutRef<typeof Menu.RadioItemIndicator>, 'children'>) {
return (
<Menu.RadioItemIndicator
className={cn(
'ml-auto flex shrink-0 items-center text-text-accent',
className,
)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
</Menu.RadioItemIndicator>
)
}
export function DropdownMenuCheckboxItem({
className,
...props
}: React.ComponentPropsWithoutRef<typeof Menu.CheckboxItem>) {
return (
<Menu.CheckboxItem
className={cn(
menuRowBaseClassName,
menuRowStateClassName,
className,
)}
{...props}
/>
)
}
export function DropdownMenuCheckboxItemIndicator({
className,
...props
}: Omit<React.ComponentPropsWithoutRef<typeof Menu.CheckboxItemIndicator>, 'children'>) {
return (
<Menu.CheckboxItemIndicator
className={cn(
'ml-auto flex shrink-0 items-center text-text-accent',
className,
)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
</Menu.CheckboxItemIndicator>
)
}
export function DropdownMenuGroupLabel({
className,
...props
}: React.ComponentPropsWithoutRef<typeof Menu.GroupLabel>) {
return (
<Menu.GroupLabel
className={cn(
'px-3 py-1 text-text-tertiary system-2xs-medium-uppercase',
className,
)}
{...props}
/>
)
}
type DropdownMenuContentProps = {
children: React.ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
positionerProps?: Omit<
React.ComponentPropsWithoutRef<typeof Menu.Positioner>,
'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset'
>
popupProps?: Omit<
React.ComponentPropsWithoutRef<typeof Menu.Popup>,
'children' | 'className'
>
}
type DropdownMenuPopupRenderProps = Required<Pick<DropdownMenuContentProps, 'children'>> & {
placement: Placement
sideOffset: number
alignOffset: number
className?: string
popupClassName?: string
positionerProps?: DropdownMenuContentProps['positionerProps']
popupProps?: DropdownMenuContentProps['popupProps']
}
function renderDropdownMenuPopup({
children,
placement,
sideOffset,
alignOffset,
className,
popupClassName,
positionerProps,
popupProps,
}: DropdownMenuPopupRenderProps) {
const { side, align } = parsePlacement(placement)
return (
<Menu.Portal>
<Menu.Positioner
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-50 outline-none', className)}
{...positionerProps}
>
<Menu.Popup
className={cn(
'max-h-[var(--available-height)] overflow-y-auto overflow-x-hidden 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 motion-reduce:transition-none',
popupClassName,
)}
{...popupProps}
>
{children}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
)
}
export function DropdownMenuContent({
children,
placement = 'bottom-end',
sideOffset = 4,
alignOffset = 0,
className,
popupClassName,
positionerProps,
popupProps,
}: DropdownMenuContentProps) {
return renderDropdownMenuPopup({
children,
placement,
sideOffset,
alignOffset,
className,
popupClassName,
positionerProps,
popupProps,
})
}
type DropdownMenuSubTriggerProps = React.ComponentPropsWithoutRef<typeof Menu.SubmenuTrigger> & {
destructive?: boolean
}
export function DropdownMenuSubTrigger({
className,
destructive,
children,
...props
}: DropdownMenuSubTriggerProps) {
return (
<Menu.SubmenuTrigger
className={cn(
menuRowBaseClassName,
menuRowStateClassName,
destructive && 'text-text-destructive',
className,
)}
{...props}
>
{children}
<span aria-hidden className="i-ri-arrow-right-s-line ml-auto size-[14px] shrink-0 text-text-tertiary" />
</Menu.SubmenuTrigger>
)
}
type DropdownMenuSubContentProps = {
children: React.ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
positionerProps?: DropdownMenuContentProps['positionerProps']
popupProps?: DropdownMenuContentProps['popupProps']
}
export function DropdownMenuSubContent({
children,
placement = 'left-start',
sideOffset = 4,
alignOffset = 0,
className,
popupClassName,
positionerProps,
popupProps,
}: DropdownMenuSubContentProps) {
return renderDropdownMenuPopup({
children,
placement,
sideOffset,
alignOffset,
className,
popupClassName,
positionerProps,
popupProps,
})
}
type DropdownMenuItemProps = React.ComponentPropsWithoutRef<typeof Menu.Item> & {
destructive?: boolean
}
export function DropdownMenuItem({
className,
destructive,
...props
}: DropdownMenuItemProps) {
return (
<Menu.Item
className={cn(
menuRowBaseClassName,
menuRowStateClassName,
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,29 @@
// Placement type for overlay positioning.
// Mirrors the Floating UI Placement spec — a stable set of 12 CSS-based position values.
// Reference: https://floating-ui.com/docs/useFloating#placement
type Side = 'top' | 'bottom' | 'left' | 'right'
type Align = 'start' | 'center' | 'end'
export type Placement
= 'top'
| 'top-start'
| 'top-end'
| 'right'
| 'right-start'
| 'right-end'
| 'bottom'
| 'bottom-start'
| 'bottom-end'
| 'left'
| 'left-start'
| 'left-end'
export function parsePlacement(placement: Placement): { side: Side, align: Align } {
const [side, align] = placement.split('-') as [Side, Align | undefined]
return {
side,
align: align ?? 'center',
}
}

View File

@@ -0,0 +1,199 @@
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
import { Popover as BasePopover } from '@base-ui/react/popover'
import { render, screen } from '@testing-library/react'
import {
Popover,
PopoverClose,
PopoverContent,
PopoverDescription,
PopoverTitle,
PopoverTrigger,
} from '..'
type PrimitiveProps = ComponentPropsWithoutRef<'div'> & {
children?: ReactNode
}
type PositionerProps = PrimitiveProps & {
side?: 'top' | 'bottom' | 'left' | 'right'
align?: 'start' | 'center' | 'end'
sideOffset?: number
alignOffset?: number
}
vi.mock('@base-ui/react/popover', () => {
const Root = ({ children, ...props }: PrimitiveProps) => (
<div {...props}>
{children}
</div>
)
const Trigger = ({ children, ...props }: ComponentPropsWithoutRef<'button'>) => (
<button type="button" {...props}>
{children}
</button>
)
const Close = ({ children, ...props }: ComponentPropsWithoutRef<'button'>) => (
<button type="button" {...props}>
{children}
</button>
)
const Title = ({ children, ...props }: ComponentPropsWithoutRef<'h2'>) => (
<h2 {...props}>
{children}
</h2>
)
const Description = ({ children, ...props }: ComponentPropsWithoutRef<'p'>) => (
<p {...props}>
{children}
</p>
)
const Portal = ({ children }: PrimitiveProps) => (
<div>{children}</div>
)
const Positioner = ({
children,
side,
align,
sideOffset,
alignOffset,
...props
}: PositionerProps) => (
<div
data-side={side}
data-align={align}
data-side-offset={String(sideOffset)}
data-align-offset={String(alignOffset)}
{...props}
>
{children}
</div>
)
const Popup = ({ children, ...props }: PrimitiveProps) => (
<div {...props}>
{children}
</div>
)
return {
Popover: {
Root,
Trigger,
Close,
Title,
Description,
Portal,
Positioner,
Popup,
},
}
})
describe('PopoverContent', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Placement and default value behaviors.
describe('Placement', () => {
it('should use bottom placement and default offsets when placement props are not provided', () => {
// Arrange
render(
<PopoverContent positionerProps={{ 'aria-label': 'default positioner' }}>
<span>Default content</span>
</PopoverContent>,
)
// Act
const positioner = screen.getByLabelText('default positioner')
// Assert
expect(positioner).toHaveAttribute('data-side', 'bottom')
expect(positioner).toHaveAttribute('data-align', 'center')
expect(positioner).toHaveAttribute('data-side-offset', '8')
expect(positioner).toHaveAttribute('data-align-offset', '0')
expect(screen.getByText('Default content')).toBeInTheDocument()
})
it('should apply parsed custom placement and custom offsets when placement props are provided', () => {
// Arrange
render(
<PopoverContent
placement="top-end"
sideOffset={14}
alignOffset={6}
positionerProps={{ 'aria-label': 'custom positioner' }}
>
<span>Custom placement content</span>
</PopoverContent>,
)
// Act
const positioner = screen.getByLabelText('custom positioner')
// Assert
expect(positioner).toHaveAttribute('data-side', 'top')
expect(positioner).toHaveAttribute('data-align', 'end')
expect(positioner).toHaveAttribute('data-side-offset', '14')
expect(positioner).toHaveAttribute('data-align-offset', '6')
})
})
// Passthrough behavior for delegated primitives.
describe('Passthrough props', () => {
it('should forward positionerProps and popupProps when passthrough props are provided', () => {
// Arrange
render(
<PopoverContent
positionerProps={{
'aria-label': 'popover positioner',
}}
popupProps={{
'id': 'popover-popup-id',
'role': 'dialog',
'aria-label': 'popover content',
}}
>
<span>Popover body</span>
</PopoverContent>,
)
// Act
const positioner = screen.getByLabelText('popover positioner')
const popup = screen.getByRole('dialog', { name: 'popover content' })
// Assert
expect(positioner).toHaveAttribute('aria-label', 'popover positioner')
expect(popup).toHaveAttribute('id', 'popover-popup-id')
expect(popup).toHaveAttribute('role', 'dialog')
expect(popup).toHaveAttribute('aria-label', 'popover content')
})
})
})
describe('Popover aliases', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Export mapping behavior to keep wrapper aliases aligned.
describe('Export mapping', () => {
it('should map aliases to the matching base popover primitives when wrapper exports are imported', () => {
// Arrange
const basePrimitives = BasePopover
// Act & Assert
expect(Popover).toBe(basePrimitives.Root)
expect(PopoverTrigger).toBe(basePrimitives.Trigger)
expect(PopoverClose).toBe(basePrimitives.Close)
expect(PopoverTitle).toBe(basePrimitives.Title)
expect(PopoverDescription).toBe(basePrimitives.Description)
})
})
})

View File

@@ -0,0 +1,67 @@
'use client'
import type { Placement } from '@/app/components/base/ui/placement'
import { Popover as BasePopover } from '@base-ui/react/popover'
import * as React from 'react'
import { parsePlacement } from '@/app/components/base/ui/placement'
import { cn } from '@/utils/classnames'
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
positionerProps?: Omit<
React.ComponentPropsWithoutRef<typeof BasePopover.Positioner>,
'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset'
>
popupProps?: Omit<
React.ComponentPropsWithoutRef<typeof BasePopover.Popup>,
'children' | 'className'
>
}
export function PopoverContent({
children,
placement = 'bottom',
sideOffset = 8,
alignOffset = 0,
className,
popupClassName,
positionerProps,
popupProps,
}: PopoverContentProps) {
const { side, align } = parsePlacement(placement)
return (
<BasePopover.Portal>
<BasePopover.Positioner
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-50 outline-none', className)}
{...positionerProps}
>
<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 motion-reduce:transition-none',
popupClassName,
)}
{...popupProps}
>
{children}
</BasePopover.Popup>
</BasePopover.Positioner>
</BasePopover.Portal>
)
}

View File

@@ -0,0 +1,325 @@
import type {
ButtonHTMLAttributes,
HTMLAttributes,
ReactNode,
} from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
SelectContent,
SelectItem,
SelectTrigger,
} from '../index'
type ParsedPlacement = {
side: 'top' | 'bottom' | 'left' | 'right'
align: 'start' | 'center' | 'end'
}
const { mockParsePlacement } = vi.hoisted(() => ({
mockParsePlacement: vi.fn<(placement: string) => ParsedPlacement>(),
}))
vi.mock('@/app/components/base/ui/placement', () => ({
parsePlacement: mockParsePlacement,
}))
vi.mock('@base-ui/react/select', () => {
type WithChildren = { children?: ReactNode }
type PositionerProps = HTMLAttributes<HTMLDivElement> & {
side?: 'top' | 'bottom' | 'left' | 'right'
align?: 'start' | 'center' | 'end'
sideOffset?: number
alignOffset?: number
alignItemWithTrigger?: boolean
}
const Root = ({ children }: WithChildren) => <div>{children}</div>
const Value = ({ children }: WithChildren) => <span>{children}</span>
const Group = ({ children }: WithChildren) => <div>{children}</div>
const GroupLabel = ({ children }: WithChildren) => <div>{children}</div>
const Separator = (props: HTMLAttributes<HTMLHRElement>) => <hr {...props} />
const Trigger = ({ children, ...props }: ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type="button" {...props}>
{children}
</button>
)
const Icon = ({ children, ...props }: HTMLAttributes<HTMLSpanElement>) => (
<span aria-label="Open select menu" role="img" {...props}>
{children}
</span>
)
const Portal = ({ children }: WithChildren) => <div>{children}</div>
const Positioner = ({
children,
side,
align,
sideOffset,
alignOffset,
alignItemWithTrigger,
className,
...props
}: PositionerProps) => (
<div
data-align={align}
data-align-offset={alignOffset}
data-align-item-with-trigger={alignItemWithTrigger === undefined ? undefined : String(alignItemWithTrigger)}
data-side={side}
data-side-offset={sideOffset}
className={className}
{...props}
>
{children}
</div>
)
const Popup = ({ children, ...props }: HTMLAttributes<HTMLDivElement>) => (
<div {...props}>
{children}
</div>
)
const List = ({ children, ...props }: HTMLAttributes<HTMLDivElement>) => (
<div {...props}>
{children}
</div>
)
const Item = ({ children, ...props }: HTMLAttributes<HTMLDivElement>) => (
<div role="option" {...props}>
{children}
</div>
)
const ItemText = ({ children, ...props }: HTMLAttributes<HTMLSpanElement>) => (
<span {...props}>
{children}
</span>
)
const ItemIndicator = ({ children, ...props }: HTMLAttributes<HTMLSpanElement>) => (
<span aria-label="Selected item indicator" role="img" {...props}>
{children}
</span>
)
return {
Select: {
Root,
Value,
Group,
GroupLabel,
Separator,
Trigger,
Icon,
Portal,
Positioner,
Popup,
List,
Item,
ItemText,
ItemIndicator,
},
}
})
describe('Select wrappers', () => {
beforeEach(() => {
vi.clearAllMocks()
mockParsePlacement.mockReturnValue({ side: 'bottom', align: 'start' })
})
// Covers default rendering and visual branches for trigger content.
describe('SelectTrigger', () => {
it('should render the default icon when clearable and loading are not enabled', () => {
// Arrange
render(<SelectTrigger>Trigger Label</SelectTrigger>)
// Assert
expect(screen.getByText('Trigger Label')).toBeInTheDocument()
expect(screen.getByRole('img', { name: /open select menu/i })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /clear selection/i })).not.toBeInTheDocument()
})
it('should render clear button when clearable is true and loading is false', () => {
// Arrange
render(<SelectTrigger clearable>Trigger Label</SelectTrigger>)
// Assert
expect(screen.getByRole('button', { name: /clear selection/i })).toBeInTheDocument()
expect(screen.queryByRole('img', { name: /open select menu/i })).not.toBeInTheDocument()
})
it('should render loading indicator and hide clear button when loading is true', () => {
// Arrange
render(<SelectTrigger clearable loading>Trigger Label</SelectTrigger>)
// Assert
expect(screen.getByRole('button', { name: /trigger label/i })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /clear selection/i })).not.toBeInTheDocument()
expect(screen.queryByRole('img', { name: /open select menu/i })).not.toBeInTheDocument()
})
it('should forward native trigger props when trigger props are provided', () => {
// Arrange
render(
<SelectTrigger
aria-label="Choose option"
disabled
>
Trigger Label
</SelectTrigger>,
)
// Assert
const trigger = screen.getByRole('button', { name: /choose option/i })
expect(trigger).toBeDisabled()
expect(trigger).toHaveAttribute('aria-label', 'Choose option')
})
it('should call onClear and stop click propagation when clear button is clicked', () => {
// Arrange
const onClear = vi.fn()
const onTriggerClick = vi.fn()
render(
<SelectTrigger clearable onClear={onClear} onClick={onTriggerClick}>
Trigger Label
</SelectTrigger>,
)
// Act
fireEvent.click(screen.getByRole('button', { name: /clear selection/i }))
// Assert
expect(onClear).toHaveBeenCalledTimes(1)
expect(onTriggerClick).not.toHaveBeenCalled()
})
it('should stop mouse down propagation when clear button receives mouse down', () => {
// Arrange
const onTriggerMouseDown = vi.fn()
render(
<SelectTrigger clearable onMouseDown={onTriggerMouseDown}>
Trigger Label
</SelectTrigger>,
)
// Act
fireEvent.mouseDown(screen.getByRole('button', { name: /clear selection/i }))
// Assert
expect(onTriggerMouseDown).not.toHaveBeenCalled()
})
it('should not throw when clear button is clicked without onClear handler', () => {
// Arrange
render(<SelectTrigger clearable>Trigger Label</SelectTrigger>)
const clearButton = screen.getByRole('button', { name: /clear selection/i })
// Act & Assert
expect(() => fireEvent.click(clearButton)).not.toThrow()
})
})
// Covers content placement parsing, forwarding props, and slot rendering.
describe('SelectContent', () => {
it('should call parsePlacement with default placement when placement is not provided', () => {
// Arrange
mockParsePlacement.mockReturnValueOnce({ side: 'bottom', align: 'start' })
// Act
render(
<SelectContent>
<span>Option A</span>
</SelectContent>,
)
// Assert
expect(mockParsePlacement).toHaveBeenCalledWith('bottom-start')
expect(screen.getByText('Option A')).toBeInTheDocument()
})
it('should pass parsed side align and offsets to Positioner when custom placement and offsets are provided', () => {
// Arrange
mockParsePlacement.mockReturnValueOnce({ side: 'top', align: 'end' })
// Act
render(
<SelectContent
alignOffset={6}
placement="top-end"
sideOffset={12}
positionerProps={{ 'role': 'group', 'aria-label': 'Select positioner' }}
>
<div>Option A</div>
</SelectContent>,
)
// Assert
const positioner = screen.getByRole('group', { name: /select positioner/i })
expect(mockParsePlacement).toHaveBeenCalledWith('top-end')
expect(positioner).toHaveAttribute('data-side', 'top')
expect(positioner).toHaveAttribute('data-align', 'end')
expect(positioner).toHaveAttribute('data-side-offset', '12')
expect(positioner).toHaveAttribute('data-align-offset', '6')
expect(positioner).toHaveAttribute('data-align-item-with-trigger', 'false')
})
it('should forward passthrough props to positioner popup and list when passthrough props are provided', () => {
// Arrange & Act
render(
<SelectContent
positionerProps={{
'role': 'group',
'aria-label': 'select positioner',
}}
popupProps={{
'role': 'dialog',
'aria-label': 'select popup',
}}
listProps={{
'role': 'listbox',
'aria-label': 'select list',
}}
>
<div>Option A</div>
</SelectContent>,
)
// Assert
const positioner = screen.getByRole('group', { name: /select positioner/i })
const popup = screen.getByRole('dialog', { name: /select popup/i })
const list = screen.getByRole('listbox', { name: /select list/i })
expect(positioner).toHaveAttribute('aria-label', 'select positioner')
expect(popup).toHaveAttribute('role', 'dialog')
expect(popup).toHaveAttribute('aria-label', 'select popup')
expect(list).toHaveAttribute('role', 'listbox')
expect(list).toHaveAttribute('aria-label', 'select list')
})
})
// Covers option item rendering and prop forwarding behavior.
describe('SelectItem', () => {
it('should render item text and indicator when children are provided', () => {
// Arrange
render(<SelectItem>Seattle</SelectItem>)
// Assert
expect(screen.getByRole('option', { name: /seattle/i })).toBeInTheDocument()
expect(screen.getByRole('img', { name: /selected item indicator/i })).toBeInTheDocument()
})
it('should forward item props when item props are provided', () => {
// Arrange
render(
<SelectItem aria-label="City option" disabled>
Seattle
</SelectItem>,
)
// Assert
const item = screen.getByRole('option', { name: /city option/i })
expect(item).toHaveAttribute('aria-label', 'City option')
expect(item).toHaveAttribute('disabled')
})
})
})

View File

@@ -0,0 +1,163 @@
'use client'
import type { Placement } from '@/app/components/base/ui/placement'
import { Select as BaseSelect } from '@base-ui/react/select'
import * as React from 'react'
import { parsePlacement } from '@/app/components/base/ui/placement'
import { cn } from '@/utils/classnames'
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
type SelectTriggerProps = React.ComponentPropsWithoutRef<typeof BaseSelect.Trigger> & {
clearable?: boolean
onClear?: () => void
loading?: boolean
}
export function SelectTrigger({
className,
children,
clearable = false,
onClear,
loading = false,
...props
}: SelectTriggerProps) {
const showClear = clearable && !loading
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}
>
<span className="grow truncate">{children}</span>
{loading
? (
<span className="ml-1 shrink-0 text-text-quaternary">
<span className="i-ri-loader-4-line h-3.5 w-3.5 animate-spin" />
</span>
)
: showClear
? (
<span
role="button"
aria-label="Clear selection"
tabIndex={-1}
className="ml-1 shrink-0 cursor-pointer text-text-quaternary hover:text-text-secondary"
onClick={(e) => {
e.stopPropagation()
onClear?.()
}}
onMouseDown={(e) => {
e.stopPropagation()
}}
>
<span className="i-ri-close-circle-fill h-3.5 w-3.5" />
</span>
)
: (
<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
positionerProps?: Omit<
React.ComponentPropsWithoutRef<typeof BaseSelect.Positioner>,
'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset'
>
popupProps?: Omit<
React.ComponentPropsWithoutRef<typeof BaseSelect.Popup>,
'children' | 'className'
>
listProps?: Omit<
React.ComponentPropsWithoutRef<typeof BaseSelect.List>,
'children' | 'className'
>
}
export function SelectContent({
children,
placement = 'bottom-start',
sideOffset = 4,
alignOffset = 0,
className,
popupClassName,
listClassName,
positionerProps,
popupProps,
listProps,
}: SelectContentProps) {
const { side, align } = parsePlacement(placement)
return (
<BaseSelect.Portal>
<BaseSelect.Positioner
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
alignItemWithTrigger={false}
className={cn('z-50 outline-none', className)}
{...positionerProps}
>
<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 motion-reduce:transition-none',
popupClassName,
)}
{...popupProps}
>
<BaseSelect.List
className={cn('max-h-80 min-w-[10rem] overflow-auto p-1 outline-none', listClassName)}
{...listProps}
>
{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,207 @@
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
import type { Placement } from '@/app/components/base/ui/placement'
import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../index'
type ParsedPlacement = {
side: 'top' | 'bottom' | 'left' | 'right'
align: 'start' | 'center' | 'end'
}
type PositionerMockProps = ComponentPropsWithoutRef<'div'> & {
side?: ParsedPlacement['side']
align?: ParsedPlacement['align']
sideOffset?: number
alignOffset?: number
}
const positionerPropsSpy = vi.fn<(props: PositionerMockProps) => void>()
const popupPropsSpy = vi.fn<(props: ComponentPropsWithoutRef<'div'>) => void>()
const parsePlacementMock = vi.fn<(placement: Placement) => ParsedPlacement>()
vi.mock('@/app/components/base/ui/placement', () => ({
parsePlacement: (placement: Placement) => parsePlacementMock(placement),
}))
vi.mock('@base-ui/react/tooltip', () => {
const Portal = ({ children }: { children?: ReactNode }) => (
<div>{children}</div>
)
const Positioner = ({ children, ...props }: PositionerMockProps) => {
positionerPropsSpy(props)
return <div>{children}</div>
}
const Popup = ({ children, className, ...props }: ComponentPropsWithoutRef<'div'>) => {
popupPropsSpy({ className, ...props })
return (
<div className={className} {...props}>
{children}
</div>
)
}
const Provider = ({ children }: { children?: ReactNode }) => (
<div>{children}</div>
)
const Root = ({ children }: { children?: ReactNode }) => (
<div>{children}</div>
)
const Trigger = ({ children }: { children?: ReactNode }) => (
<button type="button">
{children}
</button>
)
return {
Tooltip: {
Portal,
Positioner,
Popup,
Provider,
Root,
Trigger,
},
}
})
describe('TooltipContent', () => {
beforeEach(() => {
vi.clearAllMocks()
parsePlacementMock.mockReturnValue({ side: 'top', align: 'center' })
})
describe('Placement and offsets', () => {
it('should use default placement and offsets when optional props are not provided', () => {
// Arrange
render(<TooltipContent>Tooltip body</TooltipContent>)
// Act
const positionerProps = positionerPropsSpy.mock.calls.at(-1)?.[0]
// Assert
expect(parsePlacementMock).toHaveBeenCalledWith('top')
expect(positionerProps).toEqual(expect.objectContaining({
side: 'top',
align: 'center',
sideOffset: 8,
alignOffset: 0,
}))
expect(screen.getByText('Tooltip body')).toBeInTheDocument()
})
it('should use parsed placement and custom offsets when placement props are provided', () => {
// Arrange
parsePlacementMock.mockReturnValue({ side: 'bottom', align: 'start' })
const customPlacement: Placement = 'bottom-start'
// Act
render(
<TooltipContent
placement={customPlacement}
sideOffset={16}
alignOffset={6}
>
Custom tooltip body
</TooltipContent>,
)
const positionerProps = positionerPropsSpy.mock.calls.at(-1)?.[0]
// Assert
expect(parsePlacementMock).toHaveBeenCalledWith(customPlacement)
expect(positionerProps).toEqual(expect.objectContaining({
side: 'bottom',
align: 'start',
sideOffset: 16,
alignOffset: 6,
}))
expect(screen.getByText('Custom tooltip body')).toBeInTheDocument()
})
})
describe('Variant behavior', () => {
it('should compute a different popup presentation contract for plain variant than default', () => {
// Arrange
const { rerender } = render(
<TooltipContent variant="default">
Default tooltip body
</TooltipContent>,
)
const defaultPopupProps = popupPropsSpy.mock.calls.at(-1)?.[0]
// Act
rerender(
<TooltipContent variant="plain">
Plain tooltip body
</TooltipContent>,
)
const plainPopupProps = popupPropsSpy.mock.calls.at(-1)?.[0]
// Assert
expect(screen.getByText('Plain tooltip body')).toBeInTheDocument()
expect(defaultPopupProps?.className).toBeTypeOf('string')
expect(plainPopupProps?.className).toBeTypeOf('string')
expect(plainPopupProps?.className).not.toBe(defaultPopupProps?.className)
})
})
describe('Popup prop forwarding', () => {
it('should forward popup props to BaseTooltip.Popup when popup props are provided', () => {
// Arrange
render(
<TooltipContent
id="popup-id"
role="tooltip"
aria-label="help text"
data-track-id="tooltip-track"
>
Tooltip body
</TooltipContent>,
)
// Act
const popup = screen.getByRole('tooltip', { name: 'help text' })
const popupProps = popupPropsSpy.mock.calls.at(-1)?.[0]
// Assert
expect(popupProps).toEqual(expect.objectContaining({
'id': 'popup-id',
'role': 'tooltip',
'aria-label': 'help text',
'data-track-id': 'tooltip-track',
}))
expect(popup).toHaveAttribute('id', 'popup-id')
expect(popup).toHaveAttribute('role', 'tooltip')
expect(popup).toHaveAttribute('aria-label', 'help text')
expect(popup).toHaveAttribute('data-track-id', 'tooltip-track')
})
})
})
describe('Tooltip aliases', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should map alias exports to BaseTooltip components when wrapper exports are imported', () => {
// Arrange
const provider = BaseTooltip.Provider
const root = BaseTooltip.Root
const trigger = BaseTooltip.Trigger
// Act
const exportedProvider = TooltipProvider
const exportedTooltip = Tooltip
const exportedTrigger = TooltipTrigger
// Assert
expect(exportedProvider).toBe(provider)
expect(exportedTooltip).toBe(root)
expect(exportedTrigger).toBe(trigger)
})
})

View File

@@ -0,0 +1,59 @@
'use client'
import type { Placement } from '@/app/components/base/ui/placement'
import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip'
import * as React from 'react'
import { parsePlacement } from '@/app/components/base/ui/placement'
import { cn } from '@/utils/classnames'
type TooltipContentVariant = 'default' | 'plain'
export type TooltipContentProps = {
children: React.ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
variant?: TooltipContentVariant
} & Omit<React.ComponentPropsWithoutRef<typeof BaseTooltip.Popup>, 'children' | 'className'>
export function TooltipContent({
children,
placement = 'top',
sideOffset = 8,
alignOffset = 0,
className,
popupClassName,
variant = 'default',
...props
}: TooltipContentProps) {
const { side, align } = parsePlacement(placement)
return (
<BaseTooltip.Portal>
<BaseTooltip.Positioner
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-50 outline-none', className)}
>
<BaseTooltip.Popup
className={cn(
variant === 'default' && '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',
'origin-[var(--transform-origin)] transition-[opacity] data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 data-[instant]:transition-none motion-reduce:transition-none',
popupClassName,
)}
{...props}
>
{children}
</BaseTooltip.Popup>
</BaseTooltip.Positioner>
</BaseTooltip.Portal>
)
}
export const TooltipProvider = BaseTooltip.Provider
export const Tooltip = BaseTooltip.Root
export const TooltipTrigger = BaseTooltip.Trigger

View File

@@ -1,6 +1,7 @@
import type { ModalContextState } from '@/context/modal-context' import type { ModalContextState } from '@/context/modal-context'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu'
import { Plan } from '@/app/components/billing/type' import { Plan } from '@/app/components/billing/type'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context' import { useModalContext } from '@/context/modal-context'
@@ -70,16 +71,26 @@ describe('Compliance', () => {
) )
} }
// Wrapper for tests that need the menu open const renderCompliance = () => {
return renderWithQueryClient(
<DropdownMenu open={true} onOpenChange={() => {}}>
<DropdownMenuTrigger>open</DropdownMenuTrigger>
<DropdownMenuContent>
<Compliance />
</DropdownMenuContent>
</DropdownMenu>,
)
}
const openMenuAndRender = () => { const openMenuAndRender = () => {
renderWithQueryClient(<Compliance />) renderCompliance()
fireEvent.click(screen.getByRole('button')) fireEvent.click(screen.getByText('common.userProfile.compliance'))
} }
describe('Rendering', () => { describe('Rendering', () => {
it('should render compliance menu trigger', () => { it('should render compliance menu trigger', () => {
// Act // Act
renderWithQueryClient(<Compliance />) renderCompliance()
// Assert // Assert
expect(screen.getByText('common.userProfile.compliance')).toBeInTheDocument() expect(screen.getByText('common.userProfile.compliance')).toBeInTheDocument()

View File

@@ -1,9 +1,9 @@
import type { FC, MouseEvent } from 'react' import type { ReactNode } from 'react'
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import { RiArrowDownCircleLine, RiArrowRightSLine, RiVerifiedBadgeLine } from '@remixicon/react'
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import { Fragment, useCallback } from 'react' import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/app/components/base/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { Plan } from '@/app/components/billing/type' import { Plan } from '@/app/components/billing/type'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context' import { useModalContext } from '@/context/modal-context'
@@ -11,14 +11,14 @@ import { useProviderContext } from '@/context/provider-context'
import { getDocDownloadUrl } from '@/service/common' import { getDocDownloadUrl } from '@/service/common'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { downloadUrl } from '@/utils/download' import { downloadUrl } from '@/utils/download'
import Button from '../../base/button'
import Gdpr from '../../base/icons/src/public/common/Gdpr' import Gdpr from '../../base/icons/src/public/common/Gdpr'
import Iso from '../../base/icons/src/public/common/Iso' import Iso from '../../base/icons/src/public/common/Iso'
import Soc2 from '../../base/icons/src/public/common/Soc2' import Soc2 from '../../base/icons/src/public/common/Soc2'
import SparklesSoft from '../../base/icons/src/public/common/SparklesSoft' import SparklesSoft from '../../base/icons/src/public/common/SparklesSoft'
import PremiumBadge from '../../base/premium-badge' import PremiumBadge from '../../base/premium-badge'
import Spinner from '../../base/spinner'
import Toast from '../../base/toast' import Toast from '../../base/toast'
import Tooltip from '../../base/tooltip' import { MenuItemContent } from './menu-item-content'
enum DocName { enum DocName {
SOC2_Type_I = 'SOC2_Type_I', SOC2_Type_I = 'SOC2_Type_I',
@@ -27,27 +27,83 @@ enum DocName {
GDPR = 'GDPR', GDPR = 'GDPR',
} }
type UpgradeOrDownloadProps = { type ComplianceDocActionVisualProps = {
doc_name: DocName isCurrentPlanCanDownload: boolean
isPending: boolean
tooltipText: string
downloadText: string
upgradeText: string
} }
const UpgradeOrDownload: FC<UpgradeOrDownloadProps> = ({ doc_name }) => {
function ComplianceDocActionVisual({
isCurrentPlanCanDownload,
isPending,
tooltipText,
downloadText,
upgradeText,
}: ComplianceDocActionVisualProps) {
if (isCurrentPlanCanDownload) {
return (
<div
aria-hidden
className={cn(
'btn btn-small btn-secondary pointer-events-none flex items-center gap-[1px]',
isPending && 'btn-disabled',
)}
>
<span className="i-ri-arrow-down-circle-line size-[14px] text-components-button-secondary-text-disabled" />
<span className="px-[3px] text-components-button-secondary-text system-xs-medium">{downloadText}</span>
{isPending && <Spinner loading={true} className="!ml-1 !h-3 !w-3 !border-2 !text-text-tertiary" />}
</div>
)
}
const canShowUpgradeTooltip = tooltipText.length > 0
return (
<Tooltip>
<TooltipTrigger
delay={0}
disabled={!canShowUpgradeTooltip}
render={(
<PremiumBadge color="blue" allowHover={true}>
<SparklesSoft className="flex h-3.5 w-3.5 items-center py-[1px] pl-[3px] text-components-premium-badge-indigo-text-stop-0" />
<div className="px-1 system-xs-medium">
{upgradeText}
</div>
</PremiumBadge>
)}
/>
{canShowUpgradeTooltip && (
<TooltipContent>
{tooltipText}
</TooltipContent>
)}
</Tooltip>
)
}
type ComplianceDocRowItemProps = {
icon: ReactNode
label: ReactNode
docName: DocName
}
function ComplianceDocRowItem({
icon,
label,
docName,
}: ComplianceDocRowItemProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { plan } = useProviderContext() const { plan } = useProviderContext()
const { setShowPricingModal, setShowAccountSettingModal } = useModalContext() const { setShowPricingModal, setShowAccountSettingModal } = useModalContext()
const isFreePlan = plan.type === Plan.sandbox const isFreePlan = plan.type === Plan.sandbox
const handlePlanClick = useCallback(() => {
if (isFreePlan)
setShowPricingModal()
else
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
}, [isFreePlan, setShowAccountSettingModal, setShowPricingModal])
const { isPending, mutate: downloadCompliance } = useMutation({ const { isPending, mutate: downloadCompliance } = useMutation({
mutationKey: ['downloadCompliance', doc_name], mutationKey: ['downloadCompliance', docName],
mutationFn: async () => { mutationFn: async () => {
try { try {
const ret = await getDocDownloadUrl(doc_name) const ret = await getDocDownloadUrl(docName)
downloadUrl({ url: ret.url }) downloadUrl({ url: ret.url })
Toast.notify({ Toast.notify({
type: 'success', type: 'success',
@@ -63,6 +119,7 @@ const UpgradeOrDownload: FC<UpgradeOrDownloadProps> = ({ doc_name }) => {
} }
}, },
}) })
const whichPlanCanDownloadCompliance = { const whichPlanCanDownloadCompliance = {
[DocName.SOC2_Type_I]: [Plan.professional, Plan.team], [DocName.SOC2_Type_I]: [Plan.professional, Plan.team],
[DocName.SOC2_Type_II]: [Plan.team], [DocName.SOC2_Type_II]: [Plan.team],
@@ -70,118 +127,85 @@ const UpgradeOrDownload: FC<UpgradeOrDownloadProps> = ({ doc_name }) => {
[DocName.GDPR]: [Plan.team, Plan.professional, Plan.sandbox], [DocName.GDPR]: [Plan.team, Plan.professional, Plan.sandbox],
} }
const isCurrentPlanCanDownload = whichPlanCanDownloadCompliance[doc_name].includes(plan.type) const isCurrentPlanCanDownload = whichPlanCanDownloadCompliance[docName].includes(plan.type)
const handleDownloadClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault() const handleSelect = useCallback(() => {
downloadCompliance() if (isCurrentPlanCanDownload) {
}, [downloadCompliance]) if (!isPending)
if (isCurrentPlanCanDownload) { downloadCompliance()
return ( return
<Button loading={isPending} disabled={isPending} size="small" variant="secondary" className="flex items-center gap-[1px]" onClick={handleDownloadClick}> }
<RiArrowDownCircleLine className="size-[14px] text-components-button-secondary-text-disabled" />
<span className="system-xs-medium px-[3px] text-components-button-secondary-text">{t('operation.download', { ns: 'common' })}</span> if (isFreePlan)
</Button> setShowPricingModal()
) else
} setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
}, [downloadCompliance, isCurrentPlanCanDownload, isFreePlan, isPending, setShowAccountSettingModal, setShowPricingModal])
const upgradeTooltip: Record<Plan, string> = { const upgradeTooltip: Record<Plan, string> = {
[Plan.sandbox]: t('compliance.sandboxUpgradeTooltip', { ns: 'common' }), [Plan.sandbox]: t('compliance.sandboxUpgradeTooltip', { ns: 'common' }),
[Plan.professional]: t('compliance.professionalUpgradeTooltip', { ns: 'common' }), [Plan.professional]: t('compliance.professionalUpgradeTooltip', { ns: 'common' }),
[Plan.team]: '', [Plan.team]: '',
[Plan.enterprise]: '', [Plan.enterprise]: '',
} }
return ( return (
<Tooltip asChild={false} popupContent={upgradeTooltip[plan.type]}> <DropdownMenuItem
<PremiumBadge color="blue" allowHover={true} onClick={handlePlanClick}> className="h-10 justify-between py-1 pl-1 pr-2"
<SparklesSoft className="flex h-3.5 w-3.5 items-center py-[1px] pl-[3px] text-components-premium-badge-indigo-text-stop-0" /> closeOnClick={!isCurrentPlanCanDownload}
<div className="system-xs-medium"> onClick={handleSelect}
<span className="p-1"> >
{t('upgradeBtn.encourageShort', { ns: 'billing' })} {icon}
</span> <div className="grow truncate px-1 text-text-secondary system-md-regular">{label}</div>
</div> <ComplianceDocActionVisual
</PremiumBadge> isCurrentPlanCanDownload={isCurrentPlanCanDownload}
</Tooltip> isPending={isPending}
tooltipText={upgradeTooltip[plan.type]}
downloadText={t('operation.download', { ns: 'common' })}
upgradeText={t('upgradeBtn.encourageShort', { ns: 'billing' })}
/>
</DropdownMenuItem>
) )
} }
// Submenu-only: this component must be rendered within an existing DropdownMenu root.
export default function Compliance() { export default function Compliance() {
const itemClassName = `
flex items-center w-full h-10 pl-1 pr-2 py-1 text-text-secondary system-md-regular
rounded-lg hover:bg-state-base-hover gap-1
`
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<Menu as="div" className="relative h-full w-full"> <DropdownMenuSub>
{ <DropdownMenuSubTrigger>
({ open }) => ( <MenuItemContent
<> iconClassName="i-ri-verified-badge-line"
<MenuButton className={ label={t('userProfile.compliance', { ns: 'common' })}
cn('group flex h-9 w-full items-center gap-1 rounded-lg py-2 pl-3 pr-2 hover:bg-state-base-hover', open && 'bg-state-base-hover') />
} </DropdownMenuSubTrigger>
> <DropdownMenuSubContent
<RiVerifiedBadgeLine className="size-4 shrink-0 text-text-tertiary" /> popupClassName="w-[337px] divide-y divide-divider-subtle !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
<div className="system-md-regular grow px-1 text-left text-text-secondary">{t('userProfile.compliance', { ns: 'common' })}</div> >
<RiArrowRightSLine className="size-[14px] shrink-0 text-text-tertiary" /> <DropdownMenuGroup className="p-1">
</MenuButton> <ComplianceDocRowItem
<Transition icon={<Soc2 aria-hidden className="size-7 shrink-0" />}
as={Fragment} label={t('compliance.soc2Type1', { ns: 'common' })}
enter="transition ease-out duration-100" docName={DocName.SOC2_Type_I}
enterFrom="transform opacity-0 scale-95" />
enterTo="transform opacity-100 scale-100" <ComplianceDocRowItem
leave="transition ease-in duration-75" icon={<Soc2 aria-hidden className="size-7 shrink-0" />}
leaveFrom="transform opacity-100 scale-100" label={t('compliance.soc2Type2', { ns: 'common' })}
leaveTo="transform opacity-0 scale-95" docName={DocName.SOC2_Type_II}
> />
<MenuItems <ComplianceDocRowItem
className={cn( icon={<Iso aria-hidden className="size-7 shrink-0" />}
`absolute top-[1px] z-10 max-h-[70vh] w-[337px] origin-top-right -translate-x-full divide-y divide-divider-subtle overflow-y-scroll label={t('compliance.iso27001', { ns: 'common' })}
rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px] focus:outline-none docName={DocName.ISO_27001}
`, />
)} <ComplianceDocRowItem
> icon={<Gdpr aria-hidden className="size-7 shrink-0" />}
<div className="px-1 py-1"> label={t('compliance.gdpr', { ns: 'common' })}
<MenuItem> docName={DocName.GDPR}
<div />
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')} </DropdownMenuGroup>
> </DropdownMenuSubContent>
<Soc2 className="size-7 shrink-0" /> </DropdownMenuSub>
<div className="system-md-regular grow truncate px-1 text-text-secondary">{t('compliance.soc2Type1', { ns: 'common' })}</div>
<UpgradeOrDownload doc_name={DocName.SOC2_Type_I} />
</div>
</MenuItem>
<MenuItem>
<div
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
>
<Soc2 className="size-7 shrink-0" />
<div className="system-md-regular grow truncate px-1 text-text-secondary">{t('compliance.soc2Type2', { ns: 'common' })}</div>
<UpgradeOrDownload doc_name={DocName.SOC2_Type_II} />
</div>
</MenuItem>
<MenuItem>
<div
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
>
<Iso className="size-7 shrink-0" />
<div className="system-md-regular grow truncate px-1 text-text-secondary">{t('compliance.iso27001', { ns: 'common' })}</div>
<UpgradeOrDownload doc_name={DocName.ISO_27001} />
</div>
</MenuItem>
<MenuItem>
<div
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
>
<Gdpr className="size-7 shrink-0" />
<div className="system-md-regular grow truncate px-1 text-text-secondary">{t('compliance.gdpr', { ns: 'common' })}</div>
<UpgradeOrDownload doc_name={DocName.GDPR} />
</div>
</MenuItem>
</div>
</MenuItems>
</Transition>
</>
)
}
</Menu>
) )
} }

View File

@@ -65,6 +65,7 @@ vi.mock('@/context/i18n', () => ({
const { mockConfig, mockEnv } = vi.hoisted(() => ({ const { mockConfig, mockEnv } = vi.hoisted(() => ({
mockConfig: { mockConfig: {
IS_CLOUD_EDITION: false, IS_CLOUD_EDITION: false,
ZENDESK_WIDGET_KEY: '',
}, },
mockEnv: { mockEnv: {
env: { env: {
@@ -74,6 +75,7 @@ const { mockConfig, mockEnv } = vi.hoisted(() => ({
})) }))
vi.mock('@/config', () => ({ vi.mock('@/config', () => ({
get IS_CLOUD_EDITION() { return mockConfig.IS_CLOUD_EDITION }, get IS_CLOUD_EDITION() { return mockConfig.IS_CLOUD_EDITION },
get ZENDESK_WIDGET_KEY() { return mockConfig.ZENDESK_WIDGET_KEY },
IS_DEV: false, IS_DEV: false,
IS_CE_EDITION: false, IS_CE_EDITION: false,
})) }))
@@ -187,6 +189,14 @@ describe('AccountDropdown', () => {
expect(screen.getByText('test@example.com')).toBeInTheDocument() expect(screen.getByText('test@example.com')).toBeInTheDocument()
}) })
it('should set an accessible label on avatar trigger when menu trigger is rendered', () => {
// Act
renderWithRouter(<AppSelector />)
// Assert
expect(screen.getByRole('button', { name: 'common.account.account' })).toBeInTheDocument()
})
it('should show EDU badge for education accounts', () => { it('should show EDU badge for education accounts', () => {
// Arrange // Arrange
vi.mocked(useProviderContext).mockReturnValue({ vi.mocked(useProviderContext).mockReturnValue({

View File

@@ -1,26 +1,15 @@
'use client' 'use client'
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import { import type { MouseEventHandler, ReactNode } from 'react'
RiAccountCircleLine,
RiArrowRightUpLine,
RiBookOpenLine,
RiGithubLine,
RiGraduationCapFill,
RiInformation2Line,
RiLogoutBoxRLine,
RiMap2Line,
RiSettings3Line,
RiStarLine,
RiTShirt2Line,
} from '@remixicon/react'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { Fragment, useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { resetUser } from '@/app/components/base/amplitude/utils' import { resetUser } from '@/app/components/base/amplitude/utils'
import Avatar from '@/app/components/base/avatar' import Avatar from '@/app/components/base/avatar'
import PremiumBadge from '@/app/components/base/premium-badge' import PremiumBadge from '@/app/components/base/premium-badge'
import ThemeSwitcher from '@/app/components/base/theme-switcher' import ThemeSwitcher from '@/app/components/base/theme-switcher'
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { IS_CLOUD_EDITION } from '@/config' import { IS_CLOUD_EDITION } from '@/config'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
@@ -35,15 +24,90 @@ import AccountAbout from '../account-about'
import GithubStar from '../github-star' import GithubStar from '../github-star'
import Indicator from '../indicator' import Indicator from '../indicator'
import Compliance from './compliance' import Compliance from './compliance'
import { ExternalLinkIndicator, MenuItemContent } from './menu-item-content'
import Support from './support' import Support from './support'
type AccountMenuRouteItemProps = {
href: string
iconClassName: string
label: ReactNode
trailing?: ReactNode
}
function AccountMenuRouteItem({
href,
iconClassName,
label,
trailing,
}: AccountMenuRouteItemProps) {
return (
<DropdownMenuItem
className="justify-between"
render={<Link href={href} />}
>
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
</DropdownMenuItem>
)
}
type AccountMenuExternalItemProps = {
href: string
iconClassName: string
label: ReactNode
trailing?: ReactNode
}
function AccountMenuExternalItem({
href,
iconClassName,
label,
trailing,
}: AccountMenuExternalItemProps) {
return (
<DropdownMenuItem
className="justify-between"
render={<a href={href} rel="noopener noreferrer" target="_blank" />}
>
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
</DropdownMenuItem>
)
}
type AccountMenuActionItemProps = {
iconClassName: string
label: ReactNode
onClick?: MouseEventHandler<HTMLElement>
trailing?: ReactNode
}
function AccountMenuActionItem({
iconClassName,
label,
onClick,
trailing,
}: AccountMenuActionItemProps) {
return (
<DropdownMenuItem
className="justify-between"
onClick={onClick}
>
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
</DropdownMenuItem>
)
}
type AccountMenuSectionProps = {
children: ReactNode
}
function AccountMenuSection({ children }: AccountMenuSectionProps) {
return <DropdownMenuGroup className="p-1">{children}</DropdownMenuGroup>
}
export default function AppSelector() { export default function AppSelector() {
const itemClassName = `
flex items-center w-full h-8 pl-3 pr-2 text-text-secondary system-md-regular
rounded-lg hover:bg-state-base-hover cursor-pointer gap-1
`
const router = useRouter() const router = useRouter()
const [aboutVisible, setAboutVisible] = useState(false) const [aboutVisible, setAboutVisible] = useState(false)
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
const { systemFeatures } = useGlobalPublicStore() const { systemFeatures } = useGlobalPublicStore()
const { t } = useTranslation() const { t } = useTranslation()
@@ -68,161 +132,124 @@ export default function AppSelector() {
} }
return ( return (
<div className=""> <div>
<Menu as="div" className="relative inline-block text-left"> <DropdownMenu open={isAccountMenuOpen} onOpenChange={setIsAccountMenuOpen}>
{ <DropdownMenuTrigger
({ open, close }) => ( aria-label={t('account.account', { ns: 'common' })}
<> className={cn('inline-flex items-center rounded-[20px] p-0.5 hover:bg-background-default-dodge', isAccountMenuOpen && 'bg-background-default-dodge')}
<MenuButton className={cn('inline-flex items-center rounded-[20px] p-0.5 hover:bg-background-default-dodge', open && 'bg-background-default-dodge')}> >
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} /> <Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} />
</MenuButton> </DropdownMenuTrigger>
<Transition <DropdownMenuContent
as={Fragment} sideOffset={6}
enter="transition ease-out duration-100" popupClassName="w-60 max-w-80 !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
enterFrom="transform opacity-0 scale-95" >
enterTo="transform opacity-100 scale-100" <DropdownMenuGroup className="px-1 py-1">
leave="transition ease-in duration-75" <div className="flex flex-nowrap items-center py-2 pl-3 pr-2">
leaveFrom="transform opacity-100 scale-100" <div className="grow">
leaveTo="transform opacity-0 scale-95" <div className="break-all text-text-primary system-md-medium">
> {userProfile.name}
<MenuItems {isEducationAccount && (
className=" <PremiumBadge size="s" color="blue" className="ml-1 !px-2">
absolute right-0 mt-1.5 w-60 max-w-80 <span aria-hidden className="i-ri-graduation-cap-fill mr-1 h-3 w-3" />
origin-top-right divide-y divide-divider-subtle rounded-xl bg-components-panel-bg-blur shadow-lg <span className="system-2xs-medium">EDU</span>
backdrop-blur-sm focus:outline-none </PremiumBadge>
"
>
<div className="px-1 py-1">
<MenuItem disabled>
<div className="flex flex-nowrap items-center py-2 pl-3 pr-2">
<div className="grow">
<div className="system-md-medium break-all text-text-primary">
{userProfile.name}
{isEducationAccount && (
<PremiumBadge size="s" color="blue" className="ml-1 !px-2">
<RiGraduationCapFill className="mr-1 h-3 w-3" />
<span className="system-2xs-medium">EDU</span>
</PremiumBadge>
)}
</div>
<div className="system-xs-regular break-all text-text-tertiary">{userProfile.email}</div>
</div>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} />
</div>
</MenuItem>
<MenuItem>
<Link
className={cn(itemClassName, 'group', 'data-[active]:bg-state-base-hover')}
href="/account"
target="_self"
rel="noopener noreferrer"
>
<RiAccountCircleLine className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('account.account', { ns: 'common' })}</div>
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
</Link>
</MenuItem>
<MenuItem>
<div
className={cn(itemClassName, 'data-[active]:bg-state-base-hover')}
onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })}
>
<RiSettings3Line className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.settings', { ns: 'common' })}</div>
</div>
</MenuItem>
</div>
{!systemFeatures.branding.enabled && (
<>
<div className="p-1">
<MenuItem>
<Link
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
href={docLink('/use-dify/getting-started/introduction')}
target="_blank"
rel="noopener noreferrer"
>
<RiBookOpenLine className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.helpCenter', { ns: 'common' })}</div>
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
</Link>
</MenuItem>
<Support closeAccountDropdown={close} />
{IS_CLOUD_EDITION && isCurrentWorkspaceOwner && <Compliance />}
</div>
<div className="p-1">
<MenuItem>
<Link
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
href="https://roadmap.dify.ai"
target="_blank"
rel="noopener noreferrer"
>
<RiMap2Line className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.roadmap', { ns: 'common' })}</div>
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
</Link>
</MenuItem>
<MenuItem>
<Link
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
href="https://github.com/langgenius/dify"
target="_blank"
rel="noopener noreferrer"
>
<RiGithubLine className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.github', { ns: 'common' })}</div>
<div className="flex items-center gap-0.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]">
<RiStarLine className="size-3 shrink-0 text-text-tertiary" />
<GithubStar className="system-2xs-medium-uppercase text-text-tertiary" />
</div>
</Link>
</MenuItem>
{
env.NEXT_PUBLIC_SITE_ABOUT !== 'hide' && (
<MenuItem>
<div
className={cn(itemClassName, 'justify-between', 'data-[active]:bg-state-base-hover')}
onClick={() => setAboutVisible(true)}
>
<RiInformation2Line className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.about', { ns: 'common' })}</div>
<div className="flex shrink-0 items-center">
<div className="system-xs-regular mr-2 text-text-tertiary">{langGeniusVersionInfo.current_version}</div>
<Indicator color={langGeniusVersionInfo.current_version === langGeniusVersionInfo.latest_version ? 'green' : 'orange'} />
</div>
</div>
</MenuItem>
)
}
</div>
</>
)} )}
<MenuItem disabled> </div>
<div className="p-1"> <div className="break-all text-text-tertiary system-xs-regular">{userProfile.email}</div>
<div className={cn(itemClassName, 'hover:bg-transparent')}> </div>
<RiTShirt2Line className="size-4 shrink-0 text-text-tertiary" /> <Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} />
<div className="system-md-regular grow px-1 text-text-secondary">{t('theme.theme', { ns: 'common' })}</div> </div>
<ThemeSwitcher /> <AccountMenuRouteItem
</div> href="/account"
iconClassName="i-ri-account-circle-line"
label={t('account.account', { ns: 'common' })}
trailing={<ExternalLinkIndicator />}
/>
<AccountMenuActionItem
iconClassName="i-ri-settings-3-line"
label={t('userProfile.settings', { ns: 'common' })}
onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator className="!my-0 bg-divider-subtle" />
{!systemFeatures.branding.enabled && (
<>
<AccountMenuSection>
<AccountMenuExternalItem
href={docLink('/use-dify/getting-started/introduction')}
iconClassName="i-ri-book-open-line"
label={t('userProfile.helpCenter', { ns: 'common' })}
trailing={<ExternalLinkIndicator />}
/>
<Support closeAccountDropdown={() => setIsAccountMenuOpen(false)} />
{IS_CLOUD_EDITION && isCurrentWorkspaceOwner && <Compliance />}
</AccountMenuSection>
<DropdownMenuSeparator className="!my-0 bg-divider-subtle" />
<AccountMenuSection>
<AccountMenuExternalItem
href="https://roadmap.dify.ai"
iconClassName="i-ri-map-2-line"
label={t('userProfile.roadmap', { ns: 'common' })}
trailing={<ExternalLinkIndicator />}
/>
<AccountMenuExternalItem
href="https://github.com/langgenius/dify"
iconClassName="i-ri-github-line"
label={t('userProfile.github', { ns: 'common' })}
trailing={(
<div className="flex items-center gap-0.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]">
<span aria-hidden className="i-ri-star-line size-3 shrink-0 text-text-tertiary" />
<GithubStar className="text-text-tertiary system-2xs-medium-uppercase" />
</div> </div>
</MenuItem> )}
<MenuItem> />
<div className="p-1" onClick={() => handleLogout()}> {
<div env.NEXT_PUBLIC_SITE_ABOUT !== 'hide' && (
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')} <AccountMenuActionItem
> iconClassName="i-ri-information-2-line"
<RiLogoutBoxRLine className="size-4 shrink-0 text-text-tertiary" /> label={t('userProfile.about', { ns: 'common' })}
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.logout', { ns: 'common' })}</div> onClick={() => {
</div> setAboutVisible(true)
</div> setIsAccountMenuOpen(false)
</MenuItem> }}
</MenuItems> trailing={(
</Transition> <div className="flex shrink-0 items-center">
<div className="mr-2 text-text-tertiary system-xs-regular">{langGeniusVersionInfo.current_version}</div>
<Indicator color={langGeniusVersionInfo.current_version === langGeniusVersionInfo.latest_version ? 'green' : 'orange'} />
</div>
)}
/>
)
}
</AccountMenuSection>
<DropdownMenuSeparator className="!my-0 bg-divider-subtle" />
</> </>
) )}
} <AccountMenuSection>
</Menu> <DropdownMenuItem
className="cursor-default data-[highlighted]:bg-transparent"
onSelect={e => e.preventDefault()}
>
<MenuItemContent
iconClassName="i-ri-t-shirt-2-line"
label={t('theme.theme', { ns: 'common' })}
trailing={<ThemeSwitcher />}
/>
</DropdownMenuItem>
</AccountMenuSection>
<DropdownMenuSeparator className="!my-0 bg-divider-subtle" />
<AccountMenuSection>
<AccountMenuActionItem
iconClassName="i-ri-logout-box-r-line"
label={t('userProfile.logout', { ns: 'common' })}
onClick={() => {
void handleLogout()
}}
/>
</AccountMenuSection>
</DropdownMenuContent>
</DropdownMenu>
{ {
aboutVisible && <AccountAbout onCancel={() => setAboutVisible(false)} langGeniusVersionInfo={langGeniusVersionInfo} /> aboutVisible && <AccountAbout onCancel={() => setAboutVisible(false)} langGeniusVersionInfo={langGeniusVersionInfo} />
} }

View File

@@ -0,0 +1,31 @@
import type { ReactNode } from 'react'
import { cn } from '@/utils/classnames'
const menuLabelClassName = 'min-w-0 grow truncate px-1 text-text-secondary system-md-regular'
const menuLeadingIconClassName = 'size-4 shrink-0 text-text-tertiary'
export const menuTrailingIconClassName = 'size-[14px] shrink-0 text-text-tertiary'
type MenuItemContentProps = {
iconClassName: string
label: ReactNode
trailing?: ReactNode
}
export function MenuItemContent({
iconClassName,
label,
trailing,
}: MenuItemContentProps) {
return (
<>
<span aria-hidden className={cn(menuLeadingIconClassName, iconClassName)} />
<div className={menuLabelClassName}>{label}</div>
{trailing}
</>
)
}
export function ExternalLinkIndicator() {
return <span aria-hidden className={cn('i-ri-arrow-right-up-line', menuTrailingIconClassName)} />
}

View File

@@ -1,6 +1,7 @@
import type { AppContextValue } from '@/context/app-context' import type { AppContextValue } from '@/context/app-context'
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu'
import { Plan } from '@/app/components/billing/type' import { Plan } from '@/app/components/billing/type'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context' import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
@@ -93,10 +94,21 @@ describe('Support', () => {
}) })
}) })
const renderSupport = () => {
return render(
<DropdownMenu open={true} onOpenChange={() => {}}>
<DropdownMenuTrigger>open</DropdownMenuTrigger>
<DropdownMenuContent>
<Support closeAccountDropdown={mockCloseAccountDropdown} />
</DropdownMenuContent>
</DropdownMenu>,
)
}
describe('Rendering', () => { describe('Rendering', () => {
it('should render support menu trigger', () => { it('should render support menu trigger', () => {
// Act // Act
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />) renderSupport()
// Assert // Assert
expect(screen.getByText('common.userProfile.support')).toBeInTheDocument() expect(screen.getByText('common.userProfile.support')).toBeInTheDocument()
@@ -104,8 +116,8 @@ describe('Support', () => {
it('should show forum and community links when opened', () => { it('should show forum and community links when opened', () => {
// Act // Act
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />) renderSupport()
fireEvent.click(screen.getByRole('button')) fireEvent.click(screen.getByText('common.userProfile.support'))
// Assert // Assert
expect(screen.getByText('common.userProfile.forum')).toBeInTheDocument() expect(screen.getByText('common.userProfile.forum')).toBeInTheDocument()
@@ -116,8 +128,8 @@ describe('Support', () => {
describe('Plan-based Channels', () => { describe('Plan-based Channels', () => {
it('should show "Contact Us" when ZENDESK_WIDGET_KEY is present', () => { it('should show "Contact Us" when ZENDESK_WIDGET_KEY is present', () => {
// Act // Act
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />) renderSupport()
fireEvent.click(screen.getByRole('button')) fireEvent.click(screen.getByText('common.userProfile.support'))
// Assert // Assert
expect(screen.getByText('common.userProfile.contactUs')).toBeInTheDocument() expect(screen.getByText('common.userProfile.contactUs')).toBeInTheDocument()
@@ -134,8 +146,8 @@ describe('Support', () => {
}) })
// Act // Act
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />) renderSupport()
fireEvent.click(screen.getByRole('button')) fireEvent.click(screen.getByText('common.userProfile.support'))
// Assert // Assert
expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument() expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument()
@@ -147,8 +159,8 @@ describe('Support', () => {
mockZendeskKey.value = '' mockZendeskKey.value = ''
// Act // Act
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />) renderSupport()
fireEvent.click(screen.getByRole('button')) fireEvent.click(screen.getByText('common.userProfile.support'))
// Assert // Assert
expect(screen.getByText('common.userProfile.emailSupport')).toBeInTheDocument() expect(screen.getByText('common.userProfile.emailSupport')).toBeInTheDocument()
@@ -159,8 +171,8 @@ describe('Support', () => {
describe('Interactions and Links', () => { describe('Interactions and Links', () => {
it('should call toggleZendeskWindow and closeAccountDropdown when "Contact Us" is clicked', () => { it('should call toggleZendeskWindow and closeAccountDropdown when "Contact Us" is clicked', () => {
// Act // Act
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />) renderSupport()
fireEvent.click(screen.getByRole('button')) fireEvent.click(screen.getByText('common.userProfile.support'))
fireEvent.click(screen.getByText('common.userProfile.contactUs')) fireEvent.click(screen.getByText('common.userProfile.contactUs'))
// Assert // Assert
@@ -170,8 +182,8 @@ describe('Support', () => {
it('should have correct forum and community links', () => { it('should have correct forum and community links', () => {
// Act // Act
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />) renderSupport()
fireEvent.click(screen.getByRole('button')) fireEvent.click(screen.getByText('common.userProfile.support'))
// Assert // Assert
const forumLink = screen.getByText('common.userProfile.forum').closest('a') const forumLink = screen.getByText('common.userProfile.forum').closest('a')

View File

@@ -1,119 +1,85 @@
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import { RiArrowRightSLine, RiArrowRightUpLine, RiChatSmile2Line, RiDiscordLine, RiDiscussLine, RiMailSendLine, RiQuestionLine } from '@remixicon/react'
import Link from 'next/link'
import { Fragment } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/app/components/base/ui/dropdown-menu'
import { toggleZendeskWindow } from '@/app/components/base/zendesk/utils' import { toggleZendeskWindow } from '@/app/components/base/zendesk/utils'
import { Plan } from '@/app/components/billing/type' import { Plan } from '@/app/components/billing/type'
import { ZENDESK_WIDGET_KEY } from '@/config' import { ZENDESK_WIDGET_KEY } from '@/config'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { cn } from '@/utils/classnames'
import { mailToSupport } from '../utils/util' import { mailToSupport } from '../utils/util'
import { ExternalLinkIndicator, MenuItemContent } from './menu-item-content'
type SupportProps = { type SupportProps = {
closeAccountDropdown: () => void closeAccountDropdown: () => void
} }
// Submenu-only: this component must be rendered within an existing DropdownMenu root.
export default function Support({ closeAccountDropdown }: SupportProps) { export default function Support({ closeAccountDropdown }: SupportProps) {
const itemClassName = `
flex items-center w-full h-9 pl-3 pr-2 text-text-secondary system-md-regular
rounded-lg hover:bg-state-base-hover cursor-pointer gap-1
`
const { t } = useTranslation() const { t } = useTranslation()
const { plan } = useProviderContext() const { plan } = useProviderContext()
const { userProfile, langGeniusVersionInfo } = useAppContext() const { userProfile, langGeniusVersionInfo } = useAppContext()
const hasDedicatedChannel = plan.type !== Plan.sandbox const hasDedicatedChannel = plan.type !== Plan.sandbox
const hasZendeskWidget = !!ZENDESK_WIDGET_KEY?.trim()
return ( return (
<Menu as="div" className="relative h-full w-full"> <DropdownMenuSub>
{ <DropdownMenuSubTrigger>
({ open }) => ( <MenuItemContent
<> iconClassName="i-ri-question-line"
<MenuButton className={ label={t('userProfile.support', { ns: 'common' })}
cn('group flex h-9 w-full items-center gap-1 rounded-lg py-2 pl-3 pr-2 hover:bg-state-base-hover', open && 'bg-state-base-hover') />
} </DropdownMenuSubTrigger>
<DropdownMenuSubContent
popupClassName="w-[216px] divide-y divide-divider-subtle !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
>
<DropdownMenuGroup className="p-1">
{hasDedicatedChannel && hasZendeskWidget && (
<DropdownMenuItem
className="justify-between"
onClick={() => {
toggleZendeskWindow(true)
closeAccountDropdown()
}}
> >
<RiQuestionLine className="size-4 shrink-0 text-text-tertiary" /> <MenuItemContent
<div className="system-md-regular grow px-1 text-left text-text-secondary">{t('userProfile.support', { ns: 'common' })}</div> iconClassName="i-ri-chat-smile-2-line"
<RiArrowRightSLine className="size-[14px] shrink-0 text-text-tertiary" /> label={t('userProfile.contactUs', { ns: 'common' })}
</MenuButton> />
<Transition </DropdownMenuItem>
as={Fragment} )}
enter="transition ease-out duration-100" {hasDedicatedChannel && !hasZendeskWidget && (
enterFrom="transform opacity-0 scale-95" <DropdownMenuItem
enterTo="transform opacity-100 scale-100" className="justify-between"
leave="transition ease-in duration-75" render={<a href={mailToSupport(userProfile.email, plan.type, langGeniusVersionInfo?.current_version)} rel="noopener noreferrer" target="_blank" />}
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
> >
<MenuItems <MenuItemContent
className={cn( iconClassName="i-ri-mail-send-line"
`absolute top-[1px] z-10 max-h-[70vh] w-[216px] origin-top-right -translate-x-full divide-y divide-divider-subtle overflow-y-auto label={t('userProfile.emailSupport', { ns: 'common' })}
rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px] focus:outline-none trailing={<ExternalLinkIndicator />}
`, />
)} </DropdownMenuItem>
> )}
<div className="px-1 py-1"> <DropdownMenuItem
{hasDedicatedChannel && ( className="justify-between"
<MenuItem> render={<a href="https://forum.dify.ai/" rel="noopener noreferrer" target="_blank" />}
{ZENDESK_WIDGET_KEY && ZENDESK_WIDGET_KEY.trim() !== '' >
? ( <MenuItemContent
<button iconClassName="i-ri-discuss-line"
className={cn(itemClassName, 'group justify-between text-left data-[active]:bg-state-base-hover')} label={t('userProfile.forum', { ns: 'common' })}
onClick={() => { trailing={<ExternalLinkIndicator />}
toggleZendeskWindow(true) />
closeAccountDropdown() </DropdownMenuItem>
}} <DropdownMenuItem
> className="justify-between"
<RiChatSmile2Line className="size-4 shrink-0 text-text-tertiary" /> render={<a href="https://discord.gg/5AEfbxcd9k" rel="noopener noreferrer" target="_blank" />}
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.contactUs', { ns: 'common' })}</div> >
</button> <MenuItemContent
) iconClassName="i-ri-discord-line"
: ( label={t('userProfile.community', { ns: 'common' })}
<a trailing={<ExternalLinkIndicator />}
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')} />
href={mailToSupport(userProfile.email, plan.type, langGeniusVersionInfo?.current_version)} </DropdownMenuItem>
target="_blank" </DropdownMenuGroup>
rel="noopener noreferrer" </DropdownMenuSubContent>
> </DropdownMenuSub>
<RiMailSendLine className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.emailSupport', { ns: 'common' })}</div>
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
</a>
)}
</MenuItem>
)}
<MenuItem>
<Link
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
href="https://forum.dify.ai/"
target="_blank"
rel="noopener noreferrer"
>
<RiDiscussLine className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.forum', { ns: 'common' })}</div>
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
</Link>
</MenuItem>
<MenuItem>
<Link
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
href="https://discord.gg/5AEfbxcd9k"
target="_blank"
rel="noopener noreferrer"
>
<RiDiscordLine className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.community', { ns: 'common' })}</div>
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
</Link>
</MenuItem>
</div>
</MenuItems>
</Transition>
</>
)
}
</Menu>
) )
} }

View File

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

View File

@@ -43,6 +43,8 @@ This command lints the entire project and is intended for final verification bef
If a new rule causes many existing code errors or automatic fixes generate too many diffs, do not use the `--fix` option for automatic fixes. If a new rule causes many existing code errors or automatic fixes generate too many diffs, do not use the `--fix` option for automatic fixes.
You can introduce the rule first, then use the `--suppress-all` option to temporarily suppress these errors, and gradually fix them in subsequent changes. You can introduce the rule first, then use the `--suppress-all` option to temporarily suppress these errors, and gradually fix them in subsequent changes.
For overlay migration policy and cleanup phases, see [Overlay Migration Guide](./overlay-migration.md).
## Type Check ## Type Check
You should be able to see suggestions from TypeScript in your editor for all open files. You should be able to see suggestions from TypeScript in your editor for all open files.

View File

@@ -0,0 +1,50 @@
# Overlay Migration Guide
This document tracks the migration away from legacy `portal-to-follow-elem` APIs.
## Scope
- Deprecated API: `@/app/components/base/portal-to-follow-elem`
- Replacement primitives:
- `@/app/components/base/ui/tooltip`
- `@/app/components/base/ui/dropdown-menu`
- `@/app/components/base/ui/popover`
- `@/app/components/base/ui/dialog`
- `@/app/components/base/ui/select`
- Tracking issue: https://github.com/langgenius/dify/issues/32767
## ESLint policy
- `no-restricted-imports` blocks new usage of `portal-to-follow-elem`.
- The rule is enabled for normal source files and test files are excluded.
- Legacy `app/components/base/*` callers are temporarily allowlisted in ESLint config.
- New files must not be added to the allowlist without migration owner approval.
## Migration phases
1. Business/UI features outside `app/components/base/**`
- Migrate old calls to semantic primitives.
- Keep `eslint-suppressions.json` stable or shrinking.
1. Legacy base components in allowlist
- Migrate allowlisted base callers gradually.
- Remove migrated files from allowlist immediately.
1. Cleanup
- Remove remaining suppressions for `no-restricted-imports`.
- Remove legacy `portal-to-follow-elem` implementation.
## Suppression maintenance
- After each migration batch, run:
```sh
pnpm eslint --prune-suppressions --pass-on-unpruned-suppressions <changed-files>
```
- Never increase suppressions to bypass new code.
- Prefer direct migration over adding suppression entries.
## React Refresh policy for base UI primitives
- We keep primitive aliases (for example `DropdownMenu = Menu.Root`) in the same module.
- For `app/components/base/ui/**/*.tsx`, `react-refresh/only-export-components` is currently set to `off` in ESLint to avoid false positives and IDE noise during migration.
- Do not use file-level `eslint-disable` comments for this policy; keep control in the scoped ESLint override.

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ import hyoban from 'eslint-plugin-hyoban'
import sonar from 'eslint-plugin-sonarjs' import sonar from 'eslint-plugin-sonarjs'
import storybook from 'eslint-plugin-storybook' import storybook from 'eslint-plugin-storybook'
import dify from './eslint-rules/index.js' import dify from './eslint-rules/index.js'
import { OVERLAY_MIGRATION_LEGACY_BASE_FILES } from './eslint.constants.mjs'
// Enable Tailwind CSS IntelliSense mode for ESLint runs // Enable Tailwind CSS IntelliSense mode for ESLint runs
// See: tailwind-css-plugin.ts // See: tailwind-css-plugin.ts
@@ -145,4 +146,51 @@ export default antfu(
'hyoban/no-dependency-version-prefix': 'error', 'hyoban/no-dependency-version-prefix': 'error',
}, },
}, },
{
name: 'dify/base-ui-primitives',
files: ['app/components/base/ui/**/*.tsx'],
rules: {
'react-refresh/only-export-components': 'off',
},
},
{
name: 'dify/overlay-migration',
files: [GLOB_TS, GLOB_TSX],
ignores: [
...GLOB_TESTS,
...OVERLAY_MIGRATION_LEGACY_BASE_FILES,
],
rules: {
'no-restricted-imports': ['error', {
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.',
}, {
group: [
'**/base/tooltip',
'**/base/tooltip/index',
],
message: 'Deprecated: use @/app/components/base/ui/tooltip instead. See issue #32767.',
}, {
group: [
'**/base/modal',
'**/base/modal/index',
'**/base/modal/modal',
],
message: 'Deprecated: use @/app/components/base/ui/dialog instead. See issue #32767.',
}, {
group: [
'**/base/select',
'**/base/select/index',
'**/base/select/custom',
'**/base/select/pure',
],
message: 'Deprecated: use @/app/components/base/ui/select instead. See issue #32767.',
}],
}],
},
},
) )

29
web/eslint.constants.mjs Normal file
View File

@@ -0,0 +1,29 @@
export const OVERLAY_MIGRATION_LEGACY_BASE_FILES = [
'app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx',
'app/components/base/chat/chat-with-history/header/operation.tsx',
'app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown.tsx',
'app/components/base/chat/chat-with-history/sidebar/operation.tsx',
'app/components/base/chat/chat/citation/popup.tsx',
'app/components/base/chat/chat/citation/progress-tooltip.tsx',
'app/components/base/chat/chat/citation/tooltip.tsx',
'app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx',
'app/components/base/chip/index.tsx',
'app/components/base/date-and-time-picker/date-picker/index.tsx',
'app/components/base/date-and-time-picker/time-picker/index.tsx',
'app/components/base/dropdown/index.tsx',
'app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx',
'app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx',
'app/components/base/file-uploader/file-from-link-or-local/index.tsx',
'app/components/base/image-uploader/chat-image-uploader.tsx',
'app/components/base/image-uploader/text-generation-image-uploader.tsx',
'app/components/base/modal/modal.tsx',
'app/components/base/prompt-editor/plugins/context-block/component.tsx',
'app/components/base/prompt-editor/plugins/history-block/component.tsx',
'app/components/base/select/custom.tsx',
'app/components/base/select/index.tsx',
'app/components/base/select/pure.tsx',
'app/components/base/sort/index.tsx',
'app/components/base/tag-management/filter.tsx',
'app/components/base/theme-selector.tsx',
'app/components/base/tooltip/index.tsx',
]

View File

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

53
web/pnpm-lock.yaml generated
View File

@@ -60,6 +60,9 @@ importers:
'@amplitude/plugin-session-replay-browser': '@amplitude/plugin-session-replay-browser':
specifier: 1.23.6 specifier: 1.23.6
version: 1.23.6(@amplitude/rrweb@2.0.0-alpha.35)(rollup@4.56.0) 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': '@emoji-mart/data':
specifier: 1.2.1 specifier: 1.2.1
version: 1.2.1 version: 1.2.1
@@ -900,6 +903,27 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'} 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': '@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -6812,6 +6836,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
reserved-identifiers@1.2.0: reserved-identifiers@1.2.0:
resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -8316,6 +8343,30 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5 '@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': {} '@bcoe/v8-coverage@1.0.2': {}
'@braintree/sanitize-url@7.1.1': {} '@braintree/sanitize-url@7.1.1': {}
@@ -15127,6 +15178,8 @@ snapshots:
require-from-string@2.0.2: {} require-from-string@2.0.2: {}
reselect@5.1.1: {}
reserved-identifiers@1.2.0: {} reserved-identifiers@1.2.0: {}
resize-observer-polyfill@1.5.1: {} resize-observer-polyfill@1.5.1: {}