test: browser mode for dify ui (#35365)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, {{defaultContext}}:api, Dockerfile, DIFY_API_IMAGE_NAME, linux/amd64, ubuntu-latest, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, {{defaultContext}}:api, Dockerfile, DIFY_API_IMAGE_NAME, linux/arm64, ubuntu-24.04-arm, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, {{defaultContext}}, web/Dockerfile, DIFY_WEB_IMAGE_NAME, linux/amd64, ubuntu-latest, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, {{defaultContext}}, web/Dockerfile, DIFY_WEB_IMAGE_NAME, linux/arm64, ubuntu-24.04-arm, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Skip Duplicate Checks (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / Run API Tests (push) Has been cancelled
Main CI Pipeline / Skip API Tests (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Run Web Tests (push) Has been cancelled
Main CI Pipeline / Skip Web Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Run Web Full-Stack E2E (push) Has been cancelled
Main CI Pipeline / Skip Web Full-Stack E2E (push) Has been cancelled
Main CI Pipeline / Web Full-Stack E2E (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / Run VDB Tests (push) Has been cancelled
Main CI Pipeline / Skip VDB Tests (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / Run DB Migration Test (push) Has been cancelled
Main CI Pipeline / Skip DB Migration Test (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
Trigger i18n Sync on Push / trigger (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled

This commit is contained in:
Stephen Zhou
2026-04-17 20:32:12 +08:00
committed by GitHub
parent 560195f9f4
commit 3c7d6739b5
22 changed files with 1259 additions and 2778 deletions

View File

@@ -109,6 +109,9 @@ jobs:
- name: Setup web environment
uses: ./.github/actions/setup-web
- name: Install Chromium for Browser Mode
run: vp exec playwright install --with-deps chromium
- name: Run dify-ui tests
run: vp test run --coverage --silent=passed-only

View File

@@ -13,6 +13,7 @@ export default antfu(
'!e2e/**',
'!eslint.config.mjs',
'!package.json',
'!pnpm-workspace.yaml',
'!vite.config.ts',
...original,
],
@@ -35,7 +36,6 @@ export default antfu(
},
},
e18e: false,
pnpm: false,
},
markdownPreferences.configs.standard,
{

View File

@@ -99,15 +99,12 @@
"@storybook/addon-themes": "catalog:",
"@storybook/react-vite": "catalog:",
"@tailwindcss/vite": "catalog:",
"@testing-library/jest-dom": "catalog:",
"@testing-library/react": "catalog:",
"@testing-library/user-event": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@vitejs/plugin-react": "catalog:",
"@vitest/coverage-v8": "catalog:",
"class-variance-authority": "catalog:",
"happy-dom": "catalog:",
"playwright": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"storybook": "catalog:",
@@ -115,6 +112,6 @@
"typescript": "catalog:",
"vite": "catalog:",
"vite-plus": "catalog:",
"vitest": "catalog:"
"vitest-browser-react": "catalog:"
}
}

View File

@@ -1,5 +1,4 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-react'
import {
AlertDialog,
AlertDialogActions,
@@ -11,10 +10,12 @@ import {
AlertDialogTrigger,
} from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('AlertDialog wrapper', () => {
describe('Rendering', () => {
it('should render alert dialog content when dialog is open', () => {
render(
it('should render alert dialog content when dialog is open', async () => {
const screen = await render(
<AlertDialog open>
<AlertDialogContent>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
@@ -23,13 +24,12 @@ describe('AlertDialog wrapper', () => {
</AlertDialog>,
)
const dialog = screen.getByRole('alertdialog')
expect(dialog).toHaveTextContent('Confirm Delete')
expect(dialog).toHaveTextContent('This action cannot be undone.')
await expect.element(screen.getByRole('alertdialog')).toHaveTextContent('Confirm Delete')
await expect.element(screen.getByRole('alertdialog')).toHaveTextContent('This action cannot be undone.')
})
it('should not render content when dialog is closed', () => {
render(
it('should not render content when dialog is closed', async () => {
const screen = await render(
<AlertDialog open={false}>
<AlertDialogContent>
<AlertDialogTitle>Hidden Title</AlertDialogTitle>
@@ -37,13 +37,13 @@ describe('AlertDialog wrapper', () => {
</AlertDialog>,
)
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
expect(screen.container.querySelector('[role="alertdialog"]')).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className to popup', () => {
render(
it('should apply custom className to popup', async () => {
const screen = await render(
<AlertDialog open>
<AlertDialogContent className="custom-class">
<AlertDialogTitle>Title</AlertDialogTitle>
@@ -51,12 +51,11 @@ describe('AlertDialog wrapper', () => {
</AlertDialog>,
)
const dialog = screen.getByRole('alertdialog')
expect(dialog).toHaveClass('custom-class')
await expect.element(screen.getByRole('alertdialog')).toHaveClass('custom-class')
})
it('should not render a close button by default', () => {
render(
it('should not render a close button by default', async () => {
const screen = await render(
<AlertDialog open>
<AlertDialogContent>
<AlertDialogTitle>Title</AlertDialogTitle>
@@ -64,13 +63,13 @@ describe('AlertDialog wrapper', () => {
</AlertDialog>,
)
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument()
expect(() => screen.getByRole('button', { name: 'Close' }).element()).toThrow()
})
})
describe('User Interactions', () => {
it('should open and close dialog when trigger and cancel button are clicked', async () => {
render(
const screen = await render(
<AlertDialog>
<AlertDialogTrigger>Open Dialog</AlertDialogTrigger>
<AlertDialogContent>
@@ -83,21 +82,21 @@ describe('AlertDialog wrapper', () => {
</AlertDialog>,
)
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
expect(screen.container.querySelector('[role="alertdialog"]')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Open Dialog' }))
expect(await screen.findByRole('alertdialog')).toHaveTextContent('Action Required')
asHTMLElement(screen.getByRole('button', { name: 'Open Dialog' }).element()).click()
await expect.element(screen.getByRole('alertdialog')).toHaveTextContent('Action Required')
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
await waitFor(() => {
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
asHTMLElement(screen.getByRole('button', { name: 'Cancel' }).element()).click()
await vi.waitFor(() => {
expect(screen.container.querySelector('[role="alertdialog"]')).not.toBeInTheDocument()
})
})
})
describe('Composition Helpers', () => {
it('should render actions wrapper and default confirm button styles', () => {
render(
it('should render actions wrapper and default confirm button styles', async () => {
const screen = await render(
<AlertDialog open>
<AlertDialogContent>
<AlertDialogTitle>Action Required</AlertDialogTitle>
@@ -108,15 +107,14 @@ describe('AlertDialog wrapper', () => {
</AlertDialog>,
)
expect(screen.getByTestId('actions')).toHaveClass('flex', 'items-start', 'justify-end', 'gap-2', 'self-stretch', 'p-6', 'custom-actions')
const confirmButton = screen.getByRole('button', { name: 'Confirm' })
expect(confirmButton).toHaveClass('bg-components-button-destructive-primary-bg')
await expect.element(screen.getByTestId('actions')).toHaveClass('flex', 'items-start', 'justify-end', 'gap-2', 'self-stretch', 'p-6', 'custom-actions')
await expect.element(screen.getByRole('button', { name: 'Confirm' })).toHaveClass('bg-components-button-destructive-primary-bg')
})
it('should keep dialog open after confirm click and close via cancel helper', async () => {
const onConfirm = vi.fn()
render(
const screen = await render(
<AlertDialog>
<AlertDialogTrigger>Open Dialog</AlertDialogTrigger>
<AlertDialogContent>
@@ -129,16 +127,16 @@ describe('AlertDialog wrapper', () => {
</AlertDialog>,
)
fireEvent.click(screen.getByRole('button', { name: 'Open Dialog' }))
expect(await screen.findByRole('alertdialog')).toBeInTheDocument()
asHTMLElement(screen.getByRole('button', { name: 'Open Dialog' }).element()).click()
await expect.element(screen.getByRole('alertdialog')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }))
asHTMLElement(screen.getByRole('button', { name: 'Confirm' }).element()).click()
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
await expect.element(screen.getByRole('alertdialog')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
await waitFor(() => {
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
asHTMLElement(screen.getByRole('button', { name: 'Cancel' }).element()).click()
await vi.waitFor(() => {
expect(screen.container.querySelector('[role="alertdialog"]')).not.toBeInTheDocument()
})
})
})

View File

@@ -1,25 +1,25 @@
import { render, screen } from '@testing-library/react'
import { render } from 'vitest-browser-react'
import { Avatar, AvatarFallback, AvatarImage, AvatarRoot } from '..'
describe('Avatar', () => {
describe('Rendering', () => {
it('should keep the fallback visible when avatar URL is provided before image load', () => {
render(<Avatar name="John Doe" avatar="https://example.com/avatar.jpg" />)
it('should keep the fallback visible when avatar URL is provided before image load', async () => {
const screen = await render(<Avatar name="John Doe" avatar="https://example.com/avatar.jpg" />)
expect(screen.getByText('J')).toBeInTheDocument()
await expect.element(screen.getByText('J')).toBeInTheDocument()
})
it('should render fallback with uppercase initial when avatar is null', () => {
render(<Avatar name="alice" avatar={null} />)
it('should render fallback with uppercase initial when avatar is null', async () => {
const screen = await render(<Avatar name="alice" avatar={null} />)
expect(screen.queryByRole('img')).not.toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
expect(screen.container.querySelector('img')).not.toBeInTheDocument()
await expect.element(screen.getByText('A')).toBeInTheDocument()
})
it('should render the fallback when avatar is provided', () => {
render(<Avatar name="John" avatar="https://example.com/avatar.jpg" />)
it('should render the fallback when avatar is provided', async () => {
const screen = await render(<Avatar name="John" avatar="https://example.com/avatar.jpg" />)
expect(screen.getByText('J')).toBeInTheDocument()
await expect.element(screen.getByText('J')).toBeInTheDocument()
})
})
@@ -33,36 +33,36 @@ describe('Avatar', () => {
{ size: 'xl' as const, expectedClass: 'size-10' },
{ size: '2xl' as const, expectedClass: 'size-12' },
{ size: '3xl' as const, expectedClass: 'size-16' },
])('should apply $expectedClass for size="$size"', ({ size, expectedClass }) => {
const { container } = render(<Avatar name="Test" avatar={null} size={size} />)
])('should apply $expectedClass for size="$size"', async ({ size, expectedClass }) => {
const screen = await render(<Avatar name="Test" avatar={null} size={size} />)
const root = container.firstElementChild as HTMLElement
const root = screen.container.firstElementChild as HTMLElement
expect(root).toHaveClass(expectedClass)
})
it('should default to md size when size is not specified', () => {
const { container } = render(<Avatar name="Test" avatar={null} />)
it('should default to md size when size is not specified', async () => {
const screen = await render(<Avatar name="Test" avatar={null} />)
const root = container.firstElementChild as HTMLElement
const root = screen.container.firstElementChild as HTMLElement
expect(root).toHaveClass('size-8')
})
})
describe('className prop', () => {
it('should merge className with avatar variant classes on root', () => {
const { container } = render(
it('should merge className with avatar variant classes on root', async () => {
const screen = await render(
<Avatar name="Test" avatar={null} className="custom-class" />,
)
const root = container.firstElementChild as HTMLElement
const root = screen.container.firstElementChild as HTMLElement
expect(root).toHaveClass('custom-class')
expect(root).toHaveClass('rounded-full', 'bg-primary-600')
})
})
describe('Primitives', () => {
it('should support composed avatar usage through exported primitives', () => {
render(
it('should support composed avatar usage through exported primitives', async () => {
const screen = await render(
<AvatarRoot size="sm" data-testid="avatar-root">
<AvatarImage src="https://example.com/avatar.jpg" alt="Jane Doe" />
<AvatarFallback size="sm" style={{ backgroundColor: 'rgb(1, 2, 3)' }}>
@@ -71,17 +71,17 @@ describe('Avatar', () => {
</AvatarRoot>,
)
expect(screen.getByTestId('avatar-root')).toHaveClass('size-6')
expect(screen.getByText('J')).toBeInTheDocument()
expect(screen.getByText('J')).toHaveStyle({ backgroundColor: 'rgb(1, 2, 3)' })
await expect.element(screen.getByTestId('avatar-root')).toHaveClass('size-6')
await expect.element(screen.getByText('J')).toBeInTheDocument()
await expect.element(screen.getByText('J')).toHaveStyle({ backgroundColor: 'rgb(1, 2, 3)' })
})
})
describe('Edge Cases', () => {
it('should handle empty string name gracefully', () => {
const { container } = render(<Avatar name="" avatar={null} />)
it('should handle empty string name gracefully', async () => {
const screen = await render(<Avatar name="" avatar={null} />)
const fallback = container.querySelector('.text-white') as HTMLElement
const fallback = screen.container.querySelector('.text-white') as HTMLElement
expect(fallback).toBeInTheDocument()
expect(fallback.textContent).toBe('')
})
@@ -89,23 +89,23 @@ describe('Avatar', () => {
it.each([
{ name: '中文名', expected: '中', label: 'Chinese characters' },
{ name: '123User', expected: '1', label: 'number' },
])('should display first character when name starts with $label', ({ name, expected }) => {
render(<Avatar name={name} avatar={null} />)
])('should display first character when name starts with $label', async ({ name, expected }) => {
const screen = await render(<Avatar name={name} avatar={null} />)
expect(screen.getByText(expected)).toBeInTheDocument()
await expect.element(screen.getByText(expected)).toBeInTheDocument()
})
it('should handle empty string avatar as falsy value', () => {
render(<Avatar name="Test" avatar={'' as string | null} />)
it('should handle empty string avatar as falsy value', async () => {
const screen = await render(<Avatar name="Test" avatar={'' as string | null} />)
expect(screen.queryByRole('img')).not.toBeInTheDocument()
expect(screen.getByText('T')).toBeInTheDocument()
expect(screen.container.querySelector('img')).not.toBeInTheDocument()
await expect.element(screen.getByText('T')).toBeInTheDocument()
})
})
describe('onLoadingStatusChange', () => {
it('should render the fallback when avatar and onLoadingStatusChange are provided', () => {
render(
it('should render the fallback when avatar and onLoadingStatusChange are provided', async () => {
const screen = await render(
<Avatar
name="John"
avatar="https://example.com/avatar.jpg"
@@ -113,16 +113,16 @@ describe('Avatar', () => {
/>,
)
expect(screen.getByText('J')).toBeInTheDocument()
await expect.element(screen.getByText('J')).toBeInTheDocument()
})
it('should not render image when avatar is null even with onLoadingStatusChange', () => {
it('should not render image when avatar is null even with onLoadingStatusChange', async () => {
const onStatusChange = vi.fn()
render(
const screen = await render(
<Avatar name="John" avatar={null} onLoadingStatusChange={onStatusChange} />,
)
expect(screen.queryByRole('img')).not.toBeInTheDocument()
expect(screen.container.querySelector('img')).not.toBeInTheDocument()
})
})
})

View File

@@ -1,46 +1,48 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { render } from 'vitest-browser-react'
import { Button } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('Button', () => {
describe('rendering', () => {
it('renders children text', () => {
render(<Button>Click me</Button>)
expect(screen.getByRole('button')).toHaveTextContent('Click me')
it('renders children text', async () => {
const screen = await render(<Button>Click me</Button>)
await expect.element(screen.getByRole('button')).toHaveTextContent('Click me')
})
it('renders as a native button element by default', () => {
render(<Button>Click me</Button>)
expect(screen.getByRole('button').tagName).toBe('BUTTON')
it('renders as a native button element by default', async () => {
const screen = await render(<Button>Click me</Button>)
expect(screen.getByRole('button').element().tagName).toBe('BUTTON')
})
it('defaults to type="button"', () => {
render(<Button>Click me</Button>)
expect(screen.getByRole('button')).toHaveAttribute('type', 'button')
it('defaults to type="button"', async () => {
const screen = await render(<Button>Click me</Button>)
await expect.element(screen.getByRole('button')).toHaveAttribute('type', 'button')
})
it('allows type override to submit', () => {
render(<Button type="submit">Submit</Button>)
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit')
it('allows type override to submit', async () => {
const screen = await render(<Button type="submit">Submit</Button>)
await expect.element(screen.getByRole('button')).toHaveAttribute('type', 'submit')
})
it('renders custom element via render prop', () => {
render(<Button render={<a href="/test" />}>Link</Button>)
const link = screen.getByRole('link')
expect(link).toHaveTextContent('Link')
expect(link).toHaveAttribute('href', '/test')
it('renders custom element via render prop', async () => {
const screen = await render(<Button nativeButton={false} render={<a href="/test" />}>Link</Button>)
const button = screen.getByRole('button', { name: 'Link' }).element()
expect(button.tagName).toBe('A')
expect(button).toHaveAttribute('href', '/test')
})
it('applies base layout classes', () => {
render(<Button>Click me</Button>)
const btn = screen.getByRole('button')
it('applies base layout classes', async () => {
const screen = await render(<Button>Click me</Button>)
const btn = screen.getByRole('button').element()
expect(btn).toHaveClass('inline-flex', 'justify-center', 'items-center', 'cursor-pointer')
})
})
describe('variants', () => {
it('applies default secondary variant', () => {
render(<Button>Click me</Button>)
const btn = screen.getByRole('button')
it('applies default secondary variant', async () => {
const screen = await render(<Button>Click me</Button>)
const btn = screen.getByRole('button').element()
expect(btn).toHaveClass('bg-components-button-secondary-bg', 'text-components-button-secondary-text')
})
@@ -51,124 +53,124 @@ describe('Button', () => {
{ variant: 'ghost' as const, expectedClass: 'text-components-button-ghost-text' },
{ variant: 'ghost-accent' as const, expectedClass: 'hover:bg-state-accent-hover' },
{ variant: 'tertiary' as const, expectedClass: 'bg-components-button-tertiary-bg' },
])('applies $variant variant', ({ variant, expectedClass }) => {
render(<Button variant={variant}>Click me</Button>)
expect(screen.getByRole('button')).toHaveClass(expectedClass)
])('applies $variant variant', async ({ variant, expectedClass }) => {
const screen = await render(<Button variant={variant}>Click me</Button>)
await expect.element(screen.getByRole('button')).toHaveClass(expectedClass)
})
it('applies destructive tone with default variant', () => {
render(<Button tone="destructive">Click me</Button>)
expect(screen.getByRole('button')).toHaveClass('bg-components-button-destructive-secondary-bg')
it('applies destructive tone with default variant', async () => {
const screen = await render(<Button tone="destructive">Click me</Button>)
await expect.element(screen.getByRole('button')).toHaveClass('bg-components-button-destructive-secondary-bg')
})
it('applies destructive tone with primary variant', () => {
render(<Button variant="primary" tone="destructive">Click me</Button>)
expect(screen.getByRole('button')).toHaveClass('bg-components-button-destructive-primary-bg')
it('applies destructive tone with primary variant', async () => {
const screen = await render(<Button variant="primary" tone="destructive">Click me</Button>)
await expect.element(screen.getByRole('button')).toHaveClass('bg-components-button-destructive-primary-bg')
})
it('applies destructive tone with tertiary variant', () => {
render(<Button variant="tertiary" tone="destructive">Click me</Button>)
expect(screen.getByRole('button')).toHaveClass('bg-components-button-destructive-tertiary-bg')
it('applies destructive tone with tertiary variant', async () => {
const screen = await render(<Button variant="tertiary" tone="destructive">Click me</Button>)
await expect.element(screen.getByRole('button')).toHaveClass('bg-components-button-destructive-tertiary-bg')
})
it('applies destructive tone with ghost variant', () => {
render(<Button variant="ghost" tone="destructive">Click me</Button>)
expect(screen.getByRole('button')).toHaveClass('text-components-button-destructive-ghost-text')
it('applies destructive tone with ghost variant', async () => {
const screen = await render(<Button variant="ghost" tone="destructive">Click me</Button>)
await expect.element(screen.getByRole('button')).toHaveClass('text-components-button-destructive-ghost-text')
})
})
describe('sizes', () => {
it('applies default medium size', () => {
render(<Button>Click me</Button>)
expect(screen.getByRole('button')).toHaveClass('h-8', 'rounded-lg')
it('applies default medium size', async () => {
const screen = await render(<Button>Click me</Button>)
await expect.element(screen.getByRole('button')).toHaveClass('h-8', 'rounded-lg')
})
it.each([
{ size: 'small' as const, expectedClass: 'h-6' },
{ size: 'medium' as const, expectedClass: 'h-8' },
{ size: 'large' as const, expectedClass: 'h-9' },
])('applies $size size', ({ size, expectedClass }) => {
render(<Button size={size}>Click me</Button>)
expect(screen.getByRole('button')).toHaveClass(expectedClass)
])('applies $size size', async ({ size, expectedClass }) => {
const screen = await render(<Button size={size}>Click me</Button>)
await expect.element(screen.getByRole('button')).toHaveClass(expectedClass)
})
})
describe('loading', () => {
it('shows spinner when loading', () => {
render(<Button loading>Click me</Button>)
expect(screen.getByRole('button').querySelector('[aria-hidden="true"]')).toBeInTheDocument()
it('shows spinner when loading', async () => {
const screen = await render(<Button loading>Click me</Button>)
expect(screen.getByRole('button').element().querySelector('[aria-hidden="true"]')).toBeInTheDocument()
})
it('hides spinner when not loading', () => {
render(<Button loading={false}>Click me</Button>)
expect(screen.getByRole('button').querySelector('[aria-hidden="true"]')).not.toBeInTheDocument()
it('hides spinner when not loading', async () => {
const screen = await render(<Button loading={false}>Click me</Button>)
expect(screen.getByRole('button').element().querySelector('[aria-hidden="true"]')).not.toBeInTheDocument()
})
it('auto-disables when loading', () => {
render(<Button loading>Click me</Button>)
expect(screen.getByRole('button')).toBeDisabled()
it('auto-disables when loading', async () => {
const screen = await render(<Button loading>Click me</Button>)
await expect.element(screen.getByRole('button')).toBeDisabled()
})
it('sets aria-busy when loading', () => {
render(<Button loading>Click me</Button>)
expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true')
it('sets aria-busy when loading', async () => {
const screen = await render(<Button loading>Click me</Button>)
await expect.element(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true')
})
it('does not set aria-busy when not loading', () => {
render(<Button>Click me</Button>)
expect(screen.getByRole('button')).not.toHaveAttribute('aria-busy')
it('does not set aria-busy when not loading', async () => {
const screen = await render(<Button>Click me</Button>)
await expect.element(screen.getByRole('button')).not.toHaveAttribute('aria-busy')
})
})
describe('disabled', () => {
it('disables button when disabled prop is set', () => {
render(<Button disabled>Click me</Button>)
expect(screen.getByRole('button')).toBeDisabled()
it('disables button when disabled prop is set', async () => {
const screen = await render(<Button disabled>Click me</Button>)
await expect.element(screen.getByRole('button')).toBeDisabled()
})
it('keeps focusable when loading with focusableWhenDisabled', () => {
render(<Button loading focusableWhenDisabled>Loading</Button>)
const button = screen.getByRole('button')
it('keeps focusable when loading with focusableWhenDisabled', async () => {
const screen = await render(<Button loading focusableWhenDisabled>Loading</Button>)
const button = screen.getByRole('button').element()
expect(button).toHaveAttribute('aria-disabled', 'true')
})
})
describe('events', () => {
it('fires onClick when clicked', () => {
it('fires onClick when clicked', async () => {
const onClick = vi.fn()
render(<Button onClick={onClick}>Click me</Button>)
fireEvent.click(screen.getByRole('button'))
const screen = await render(<Button onClick={onClick}>Click me</Button>)
await screen.getByRole('button').click()
expect(onClick).toHaveBeenCalledTimes(1)
})
it('does not fire onClick when disabled', () => {
it('does not fire onClick when disabled', async () => {
const onClick = vi.fn()
render(<Button onClick={onClick} disabled>Click me</Button>)
fireEvent.click(screen.getByRole('button'))
const screen = await render(<Button onClick={onClick} disabled>Click me</Button>)
asHTMLElement(screen.getByRole('button').element()).click()
expect(onClick).not.toHaveBeenCalled()
})
it('does not fire onClick when loading', () => {
it('does not fire onClick when loading', async () => {
const onClick = vi.fn()
render(<Button onClick={onClick} loading>Click me</Button>)
fireEvent.click(screen.getByRole('button'))
const screen = await render(<Button onClick={onClick} loading>Click me</Button>)
asHTMLElement(screen.getByRole('button').element()).click()
expect(onClick).not.toHaveBeenCalled()
})
})
describe('className merging', () => {
it('merges custom className with variant classes', () => {
render(<Button className="custom-class">Click me</Button>)
const btn = screen.getByRole('button')
it('merges custom className with variant classes', async () => {
const screen = await render(<Button className="custom-class">Click me</Button>)
const btn = screen.getByRole('button').element()
expect(btn).toHaveClass('custom-class')
expect(btn).toHaveClass('inline-flex')
})
})
describe('ref forwarding', () => {
it('forwards ref to the button element', () => {
it('forwards ref to the button element', async () => {
let buttonRef: HTMLButtonElement | null = null
render(
await render(
<Button ref={(el) => {
buttonRef = el
}}

View File

@@ -1,5 +1,4 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-react'
import {
ContextMenu,
ContextMenuContent,
@@ -12,10 +11,16 @@ import {
ContextMenuTrigger,
} from '../index'
const renderWithSafeViewport = (ui: import('react').ReactNode) => render(
<div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}>
{ui}
</div>,
)
describe('context-menu wrapper', () => {
describe('ContextMenuContent', () => {
it('should position content at bottom-start with default placement when props are omitted', () => {
render(
it('should position content at bottom-start with default placement when props are omitted', async () => {
const screen = await renderWithSafeViewport(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent positionerProps={{ 'role': 'group', 'aria-label': 'content positioner' }}>
@@ -24,15 +29,13 @@ describe('context-menu wrapper', () => {
</ContextMenu>,
)
const positioner = screen.getByRole('group', { name: 'content positioner' })
const popup = screen.getByRole('menu')
expect(positioner).toHaveAttribute('data-side', 'bottom')
expect(positioner).toHaveAttribute('data-align', 'start')
expect(within(popup).getByRole('menuitem', { name: 'Content action' })).toBeInTheDocument()
await expect.element(screen.getByRole('group', { name: 'content positioner' })).toHaveAttribute('data-side', 'bottom')
await expect.element(screen.getByRole('group', { name: 'content positioner' })).toHaveAttribute('data-align', 'start')
await expect.element(screen.getByRole('menuitem', { name: 'Content action' })).toBeInTheDocument()
})
it('should apply custom placement when custom positioning props are provided', () => {
render(
it('should apply custom top placement and keep point-anchor alignment stable when custom positioning props are provided', async () => {
const screen = await renderWithSafeViewport(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent
@@ -46,18 +49,16 @@ describe('context-menu wrapper', () => {
</ContextMenu>,
)
const positioner = screen.getByRole('group', { name: 'custom content positioner' })
const popup = screen.getByRole('menu')
expect(positioner).toHaveAttribute('data-side', 'top')
expect(positioner).toHaveAttribute('data-align', 'end')
expect(within(popup).getByRole('menuitem', { name: 'Custom content' })).toBeInTheDocument()
await expect.element(screen.getByRole('group', { name: 'custom content positioner' })).toHaveAttribute('data-side', 'top')
await expect.element(screen.getByRole('group', { name: 'custom content positioner' })).toHaveAttribute('data-align', 'start')
await expect.element(screen.getByRole('menuitem', { name: 'Custom content' })).toBeInTheDocument()
})
it('should forward passthrough attributes and handlers when positionerProps and popupProps are provided', () => {
it('should forward passthrough attributes and handlers when positionerProps and popupProps are provided', async () => {
const handlePositionerMouseEnter = vi.fn()
const handlePopupClick = vi.fn()
render(
const screen = await render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent
@@ -78,20 +79,19 @@ describe('context-menu wrapper', () => {
</ContextMenu>,
)
const positioner = screen.getByRole('group', { name: 'context content positioner' })
const popup = screen.getByRole('menu')
fireEvent.mouseEnter(positioner)
fireEvent.click(popup)
expect(positioner).toHaveAttribute('id', 'context-content-positioner')
expect(popup).toHaveAttribute('id', 'context-content-popup')
await screen.getByRole('group', { name: 'context content positioner' }).hover()
await screen.getByRole('menu').click()
await expect.element(screen.getByRole('group', { name: 'context content positioner' })).toHaveAttribute('id', 'context-content-positioner')
await expect.element(screen.getByRole('menu')).toHaveAttribute('id', 'context-content-popup')
expect(handlePositionerMouseEnter).toHaveBeenCalledTimes(1)
expect(handlePopupClick).toHaveBeenCalledTimes(1)
})
})
describe('ContextMenuSubContent', () => {
it('should position sub-content at right-start with default placement when props are omitted', () => {
render(
it('should position sub-content at right-start with default placement when props are omitted', async () => {
const screen = await renderWithSafeViewport(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
@@ -105,18 +105,17 @@ describe('context-menu wrapper', () => {
</ContextMenu>,
)
const positioner = screen.getByRole('group', { name: 'sub positioner' })
expect(positioner).toHaveAttribute('data-side', 'right')
expect(positioner).toHaveAttribute('data-align', 'start')
expect(screen.getByRole('menuitem', { name: 'Sub action' })).toBeInTheDocument()
await expect.element(screen.getByRole('group', { name: 'sub positioner' })).toHaveAttribute('data-side', 'right')
await expect.element(screen.getByRole('group', { name: 'sub positioner' })).toHaveAttribute('data-align', 'start')
await expect.element(screen.getByRole('menuitem', { name: 'Sub action' })).toBeInTheDocument()
})
})
describe('variant prop behavior', () => {
it.each(['default', 'destructive'] as const)('should remain interactive and set data-variant on item when variant is %s', (variant) => {
it.each(['default', 'destructive'] as const)('should remain interactive and set data-variant on item when variant is %s', async (variant) => {
const handleClick = vi.fn()
render(
const screen = await render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
@@ -132,17 +131,16 @@ describe('context-menu wrapper', () => {
</ContextMenu>,
)
const item = screen.getByRole('menuitem', { name: 'menu action' })
fireEvent.click(item)
expect(item).toHaveAttribute('id', `context-item-${variant}`)
expect(item).toHaveAttribute('data-variant', variant)
await screen.getByRole('menuitem', { name: 'menu action' }).click()
await expect.element(screen.getByRole('menuitem', { name: 'menu action' })).toHaveAttribute('id', `context-item-${variant}`)
await expect.element(screen.getByRole('menuitem', { name: 'menu action' })).toHaveAttribute('data-variant', variant)
expect(handleClick).toHaveBeenCalledTimes(1)
})
it.each(['default', 'destructive'] as const)('should remain interactive and set data-variant on submenu trigger when variant is %s', (variant) => {
it.each(['default', 'destructive'] as const)('should remain interactive and set data-variant on submenu trigger when variant is %s', async (variant) => {
const handleClick = vi.fn()
render(
const screen = await render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
@@ -160,15 +158,14 @@ describe('context-menu wrapper', () => {
</ContextMenu>,
)
const trigger = screen.getByRole('menuitem', { name: 'submenu action' })
fireEvent.click(trigger)
expect(trigger).toHaveAttribute('id', `context-sub-${variant}`)
expect(trigger).toHaveAttribute('data-variant', variant)
await screen.getByRole('menuitem', { name: 'submenu action' }).click()
await expect.element(screen.getByRole('menuitem', { name: 'submenu action' })).toHaveAttribute('id', `context-sub-${variant}`)
await expect.element(screen.getByRole('menuitem', { name: 'submenu action' })).toHaveAttribute('data-variant', variant)
expect(handleClick).toHaveBeenCalledTimes(1)
})
it.each(['default', 'destructive'] as const)('should remain interactive and set data-variant on link item when variant is %s', (variant) => {
render(
it.each(['default', 'destructive'] as const)('should remain interactive and set data-variant on link item when variant is %s', async (variant) => {
const screen = await render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
@@ -186,7 +183,7 @@ describe('context-menu wrapper', () => {
</ContextMenu>,
)
const link = screen.getByRole('menuitem', { name: 'context docs link' })
const link = screen.getByRole('menuitem', { name: 'context docs link' }).element()
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('id', `context-link-${variant}`)
expect(link).toHaveAttribute('data-variant', variant)
@@ -194,8 +191,8 @@ describe('context-menu wrapper', () => {
})
describe('ContextMenuLinkItem close behavior', () => {
it('should keep link semantics and not leak closeOnClick prop when closeOnClick is false', () => {
render(
it('should keep link semantics and not leak closeOnClick prop when closeOnClick is false', async () => {
const screen = await render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
@@ -210,7 +207,7 @@ describe('context-menu wrapper', () => {
</ContextMenu>,
)
const link = screen.getByRole('menuitem', { name: 'docs link' })
const link = screen.getByRole('menuitem', { name: 'docs link' }).element()
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('href', 'https://example.com/docs')
expect(link).not.toHaveAttribute('closeOnClick')
@@ -218,8 +215,8 @@ describe('context-menu wrapper', () => {
})
describe('ContextMenuTrigger interaction', () => {
it('should open menu when right-clicking trigger area', () => {
render(
it('should open menu when right-clicking trigger area', async () => {
const screen = await render(
<ContextMenu>
<ContextMenuTrigger aria-label="context trigger area">
Trigger area
@@ -230,15 +227,19 @@ describe('context-menu wrapper', () => {
</ContextMenu>,
)
const trigger = screen.getByLabelText('context trigger area')
fireEvent.contextMenu(trigger)
expect(screen.getByRole('menuitem', { name: 'Open on right click' })).toBeInTheDocument()
screen.getByLabelText('context trigger area').element().dispatchEvent(new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
button: 2,
}))
await expect.element(screen.getByRole('menuitem', { name: 'Open on right click' })).toBeInTheDocument()
})
})
describe('ContextMenuSeparator', () => {
it('should render separator and keep surrounding rows when separator is between items', () => {
render(
it('should render separator and keep surrounding rows when separator is between items', async () => {
const screen = await render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
@@ -249,9 +250,9 @@ describe('context-menu wrapper', () => {
</ContextMenu>,
)
expect(screen.getByRole('menuitem', { name: 'First action' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'Second action' })).toBeInTheDocument()
expect(screen.getAllByRole('separator')).toHaveLength(1)
await expect.element(screen.getByRole('menuitem', { name: 'First action' })).toBeInTheDocument()
await expect.element(screen.getByRole('menuitem', { name: 'Second action' })).toBeInTheDocument()
expect(screen.getByRole('separator').elements()).toHaveLength(1)
})
})
})

View File

@@ -1,5 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-react'
import {
Dialog,
DialogCloseButton,
@@ -8,10 +7,12 @@ import {
DialogTitle,
} from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('Dialog wrapper', () => {
describe('Rendering', () => {
it('should render dialog content when dialog is open', () => {
render(
it('should render dialog content when dialog is open', async () => {
const screen = await render(
<Dialog open>
<DialogContent>
<DialogTitle>Dialog Title</DialogTitle>
@@ -20,15 +21,14 @@ describe('Dialog wrapper', () => {
</Dialog>,
)
const dialog = screen.getByRole('dialog')
expect(dialog).toHaveTextContent('Dialog Title')
expect(dialog).toHaveTextContent('Dialog Description')
await expect.element(screen.getByRole('dialog')).toHaveTextContent('Dialog Title')
await expect.element(screen.getByRole('dialog')).toHaveTextContent('Dialog Description')
})
})
describe('Props', () => {
it('should not render close button when DialogCloseButton is not provided', () => {
render(
it('should not render close button when DialogCloseButton is not provided', async () => {
const screen = await render(
<Dialog open>
<DialogContent>
<span>Dialog body</span>
@@ -36,11 +36,11 @@ describe('Dialog wrapper', () => {
</Dialog>,
)
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument()
expect(() => screen.getByRole('button', { name: 'Close' }).element()).toThrow()
})
it('should render explicit close button with custom aria-label', () => {
render(
it('should render explicit close button with custom aria-label', async () => {
const screen = await render(
<Dialog open>
<DialogContent>
<DialogCloseButton aria-label="Dismiss dialog" />
@@ -49,11 +49,11 @@ describe('Dialog wrapper', () => {
</Dialog>,
)
expect(screen.getByRole('button', { name: 'Dismiss dialog' })).toBeInTheDocument()
await expect.element(screen.getByRole('button', { name: 'Dismiss dialog' })).toBeInTheDocument()
})
it('should render default close button label when aria-label is omitted', () => {
render(
it('should render default close button label when aria-label is omitted', async () => {
const screen = await render(
<Dialog open>
<DialogContent>
<DialogCloseButton />
@@ -62,12 +62,12 @@ describe('Dialog wrapper', () => {
</Dialog>,
)
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
await expect.element(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
})
it('should forward close button props to base primitive', () => {
it('should forward close button props to base primitive', async () => {
const onClick = vi.fn()
render(
const screen = await render(
<Dialog open>
<DialogContent>
<DialogCloseButton data-testid="close-button" disabled onClick={onClick} />
@@ -77,8 +77,8 @@ describe('Dialog wrapper', () => {
)
const closeButton = screen.getByTestId('close-button')
expect(closeButton).toBeDisabled()
fireEvent.click(closeButton)
await expect.element(closeButton).toBeDisabled()
asHTMLElement(closeButton.element()).click()
expect(onClick).not.toHaveBeenCalled()
})
})

View File

@@ -1,5 +1,4 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -12,10 +11,16 @@ import {
DropdownMenuTrigger,
} from '../index'
const renderWithSafeViewport = (ui: import('react').ReactNode) => render(
<div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}>
{ui}
</div>,
)
describe('dropdown-menu wrapper', () => {
describe('DropdownMenuContent', () => {
it('should position content at bottom-end with default placement when props are omitted', () => {
render(
it('should position content at bottom-end with default placement when props are omitted', async () => {
const screen = await renderWithSafeViewport(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent positionerProps={{ 'role': 'group', 'aria-label': 'content positioner' }}>
@@ -24,16 +29,13 @@ describe('dropdown-menu wrapper', () => {
</DropdownMenu>,
)
const positioner = screen.getByRole('group', { name: 'content positioner' })
const popup = screen.getByRole('menu')
expect(positioner).toHaveAttribute('data-side', 'bottom')
expect(positioner).toHaveAttribute('data-align', 'end')
expect(within(popup).getByRole('menuitem', { name: 'Content action' })).toBeInTheDocument()
await expect.element(screen.getByRole('group', { name: 'content positioner' })).toHaveAttribute('data-side', 'bottom')
await expect.element(screen.getByRole('group', { name: 'content positioner' })).toHaveAttribute('data-align', 'end')
await expect.element(screen.getByRole('menuitem', { name: 'Content action' })).toBeInTheDocument()
})
it('should apply custom placement when custom positioning props are provided', () => {
render(
it('should apply custom placement when custom positioning props are provided', async () => {
const screen = await renderWithSafeViewport(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent
@@ -47,19 +49,16 @@ describe('dropdown-menu wrapper', () => {
</DropdownMenu>,
)
const positioner = screen.getByRole('group', { name: 'custom content positioner' })
const popup = screen.getByRole('menu')
expect(positioner).toHaveAttribute('data-side', 'top')
expect(positioner).toHaveAttribute('data-align', 'start')
expect(within(popup).getByRole('menuitem', { name: 'Custom content' })).toBeInTheDocument()
await expect.element(screen.getByRole('group', { name: 'custom content positioner' })).toHaveAttribute('data-side', 'top')
await expect.element(screen.getByRole('group', { name: 'custom content positioner' })).toHaveAttribute('data-align', 'start')
await expect.element(screen.getByRole('menuitem', { name: 'Custom content' })).toBeInTheDocument()
})
it('should forward passthrough attributes and handlers when positionerProps and popupProps are provided', () => {
it('should forward passthrough attributes and handlers when positionerProps and popupProps are provided', async () => {
const handlePositionerMouseEnter = vi.fn()
const handlePopupClick = vi.fn()
render(
const screen = await render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent
@@ -80,21 +79,19 @@ describe('dropdown-menu wrapper', () => {
</DropdownMenu>,
)
const positioner = screen.getByRole('group', { name: 'dropdown content positioner' })
const popup = screen.getByRole('menu')
fireEvent.mouseEnter(positioner)
fireEvent.click(popup)
await screen.getByRole('group', { name: 'dropdown content positioner' }).hover()
await screen.getByRole('menu').click()
expect(positioner).toHaveAttribute('id', 'dropdown-content-positioner')
expect(popup).toHaveAttribute('id', 'dropdown-content-popup')
await expect.element(screen.getByRole('group', { name: 'dropdown content positioner' })).toHaveAttribute('id', 'dropdown-content-positioner')
await expect.element(screen.getByRole('menu')).toHaveAttribute('id', 'dropdown-content-popup')
expect(handlePositionerMouseEnter).toHaveBeenCalledTimes(1)
expect(handlePopupClick).toHaveBeenCalledTimes(1)
})
})
describe('DropdownMenuSubContent', () => {
it('should position sub-content at left-start with default placement when props are omitted', () => {
render(
it('should position sub-content at left-start with default placement when props are omitted', async () => {
const screen = await renderWithSafeViewport(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
@@ -108,17 +105,16 @@ describe('dropdown-menu wrapper', () => {
</DropdownMenu>,
)
const positioner = screen.getByRole('group', { name: 'sub positioner' })
expect(positioner).toHaveAttribute('data-side', 'left')
expect(positioner).toHaveAttribute('data-align', 'start')
expect(screen.getByRole('menuitem', { name: 'Sub action' })).toBeInTheDocument()
await expect.element(screen.getByRole('group', { name: 'sub positioner' })).toHaveAttribute('data-side', 'left')
await expect.element(screen.getByRole('group', { name: 'sub positioner' })).toHaveAttribute('data-align', 'start')
await expect.element(screen.getByRole('menuitem', { name: 'Sub action' })).toBeInTheDocument()
})
it('should apply custom placement and forward passthrough props for sub-content when custom props are provided', () => {
it('should apply custom placement and forward passthrough props for sub-content when custom props are provided', async () => {
const handlePositionerFocus = vi.fn()
const handlePopupClick = vi.fn()
render(
const screen = await render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
@@ -147,23 +143,23 @@ describe('dropdown-menu wrapper', () => {
</DropdownMenu>,
)
const positioner = screen.getByRole('group', { name: 'dropdown sub positioner' })
const popup = screen.getByRole('menu', { name: 'More actions' })
fireEvent.focus(positioner)
fireEvent.click(popup)
screen.getByRole('group', { name: 'dropdown sub positioner' }).element().dispatchEvent(new FocusEvent('focus', {
bubbles: true,
}))
await screen.getByRole('menu', { name: 'More actions' }).click()
expect(positioner).toHaveAttribute('data-side', 'right')
expect(positioner).toHaveAttribute('data-align', 'end')
expect(positioner).toHaveAttribute('id', 'dropdown-sub-positioner')
expect(popup).toHaveAttribute('id', 'dropdown-sub-popup')
await expect.element(screen.getByRole('group', { name: 'dropdown sub positioner' })).toHaveAttribute('data-side', 'right')
await expect.element(screen.getByRole('group', { name: 'dropdown sub positioner' })).toHaveAttribute('data-align', 'end')
await expect.element(screen.getByRole('group', { name: 'dropdown sub positioner' })).toHaveAttribute('id', 'dropdown-sub-positioner')
await expect.element(screen.getByRole('menu', { name: 'More actions' })).toHaveAttribute('id', 'dropdown-sub-popup')
expect(handlePositionerFocus).toHaveBeenCalledTimes(1)
expect(handlePopupClick).toHaveBeenCalledTimes(1)
})
})
describe('DropdownMenuSubTrigger', () => {
it('should render submenu trigger content when trigger children are provided', () => {
render(
it('should render submenu trigger content when trigger children are provided', async () => {
const screen = await render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
@@ -174,13 +170,13 @@ describe('dropdown-menu wrapper', () => {
</DropdownMenu>,
)
expect(screen.getByRole('menuitem', { name: 'Trigger item' })).toBeInTheDocument()
await expect.element(screen.getByRole('menuitem', { name: 'Trigger item' })).toBeInTheDocument()
})
it.each(['default', 'destructive'] as const)('should remain interactive and set data-variant on submenu trigger when variant is %s', (variant) => {
it.each(['default', 'destructive'] as const)('should remain interactive and set data-variant on submenu trigger when variant is %s', async (variant) => {
const handleClick = vi.fn()
render(
const screen = await render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
@@ -198,20 +194,18 @@ describe('dropdown-menu wrapper', () => {
</DropdownMenu>,
)
const subTrigger = screen.getByRole('menuitem', { name: 'submenu action' })
fireEvent.click(subTrigger)
expect(subTrigger).toHaveAttribute('id', `submenu-trigger-${variant}`)
expect(subTrigger).toHaveAttribute('data-variant', variant)
await screen.getByRole('menuitem', { name: 'submenu action' }).click()
await expect.element(screen.getByRole('menuitem', { name: 'submenu action' })).toHaveAttribute('id', `submenu-trigger-${variant}`)
await expect.element(screen.getByRole('menuitem', { name: 'submenu action' })).toHaveAttribute('data-variant', variant)
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
describe('DropdownMenuItem', () => {
it.each(['default', 'destructive'] as const)('should remain interactive and set data-variant when variant is %s', (variant) => {
it.each(['default', 'destructive'] as const)('should remain interactive and set data-variant when variant is %s', async (variant) => {
const handleClick = vi.fn()
render(
const screen = await render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
@@ -227,18 +221,16 @@ describe('dropdown-menu wrapper', () => {
</DropdownMenu>,
)
const item = screen.getByRole('menuitem', { name: 'menu action' })
fireEvent.click(item)
expect(item).toHaveAttribute('id', `menu-item-${variant}`)
expect(item).toHaveAttribute('data-variant', variant)
await screen.getByRole('menuitem', { name: 'menu action' }).click()
await expect.element(screen.getByRole('menuitem', { name: 'menu action' })).toHaveAttribute('id', `menu-item-${variant}`)
await expect.element(screen.getByRole('menuitem', { name: 'menu action' })).toHaveAttribute('data-variant', variant)
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
describe('DropdownMenuLinkItem', () => {
it('should render as anchor and keep href/target attributes when link props are provided', () => {
render(
it('should render as anchor and keep href/target attributes when link props are provided', async () => {
const screen = await render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
@@ -249,15 +241,15 @@ describe('dropdown-menu wrapper', () => {
</DropdownMenu>,
)
const link = screen.getByRole('menuitem', { name: 'Docs' })
const link = screen.getByRole('menuitem', { name: 'Docs' }).element()
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('href', 'https://example.com/docs')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
it('should keep link semantics and not leak closeOnClick prop when closeOnClick is false', () => {
render(
it('should keep link semantics and not leak closeOnClick prop when closeOnClick is false', async () => {
const screen = await render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
@@ -272,14 +264,14 @@ describe('dropdown-menu wrapper', () => {
</DropdownMenu>,
)
const link = screen.getByRole('menuitem', { name: 'docs link' })
const link = screen.getByRole('menuitem', { name: 'docs link' }).element()
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('href', 'https://example.com/docs')
expect(link).not.toHaveAttribute('closeOnClick')
})
it('should preserve link semantics when render prop uses a custom anchor element', () => {
render(
it('should preserve link semantics when render prop uses a custom anchor element', async () => {
const screen = await render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
@@ -293,16 +285,16 @@ describe('dropdown-menu wrapper', () => {
</DropdownMenu>,
)
const link = screen.getByRole('menuitem', { name: 'account link' })
const link = screen.getByRole('menuitem', { name: 'account link' }).element()
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('href', '/account')
expect(link).toHaveTextContent('Account settings')
})
it.each(['default', 'destructive'] as const)('should remain interactive and set data-variant when variant is %s', (variant) => {
it.each(['default', 'destructive'] as const)('should remain interactive and set data-variant when variant is %s', async (variant) => {
const handleClick = vi.fn()
render(
const screen = await render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
@@ -311,7 +303,10 @@ describe('dropdown-menu wrapper', () => {
href="https://example.com/docs"
aria-label="docs link"
id={`menu-link-${variant}`}
onClick={handleClick}
onClick={(event) => {
event.preventDefault()
handleClick(event)
}}
>
Docs
</DropdownMenuLinkItem>
@@ -319,9 +314,9 @@ describe('dropdown-menu wrapper', () => {
</DropdownMenu>,
)
const link = screen.getByRole('menuitem', { name: 'docs link' })
fireEvent.click(link)
await screen.getByRole('menuitem', { name: 'docs link' }).click()
const link = screen.getByRole('menuitem', { name: 'docs link' }).element()
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('id', `menu-link-${variant}`)
expect(link).toHaveAttribute('data-variant', variant)
@@ -330,10 +325,10 @@ describe('dropdown-menu wrapper', () => {
})
describe('DropdownMenuSeparator', () => {
it('should forward passthrough props and handlers when separator props are provided', () => {
it('should forward passthrough props and handlers when separator props are provided', async () => {
const handleMouseEnter = vi.fn()
render(
const screen = await render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
@@ -346,15 +341,15 @@ describe('dropdown-menu wrapper', () => {
</DropdownMenu>,
)
const separator = screen.getByRole('separator', { name: 'actions divider' })
fireEvent.mouseEnter(separator)
expect(separator).toHaveAttribute('id', 'menu-separator')
screen.getByRole('separator', { name: 'actions divider' }).element().dispatchEvent(new MouseEvent('mouseover', {
bubbles: true,
}))
await expect.element(screen.getByRole('separator', { name: 'actions divider' })).toHaveAttribute('id', 'menu-separator')
expect(handleMouseEnter).toHaveBeenCalledTimes(1)
})
it('should keep surrounding menu rows rendered when separator is placed between items', () => {
render(
it('should keep surrounding menu rows rendered when separator is placed between items', async () => {
const screen = await render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
@@ -365,9 +360,9 @@ describe('dropdown-menu wrapper', () => {
</DropdownMenu>,
)
expect(screen.getByRole('menuitem', { name: 'First action' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'Second action' })).toBeInTheDocument()
expect(screen.getAllByRole('separator')).toHaveLength(1)
await expect.element(screen.getByRole('menuitem', { name: 'First action' })).toBeInTheDocument()
await expect.element(screen.getByRole('menuitem', { name: 'Second action' })).toBeInTheDocument()
expect(screen.getByRole('separator').elements()).toHaveLength(1)
})
})
})

View File

@@ -6,7 +6,7 @@ import type {
NumberFieldInputProps,
NumberFieldUnitProps,
} from '../index'
import { render, screen } from '@testing-library/react'
import { render } from 'vitest-browser-react'
import {
NumberField,
NumberFieldControls,
@@ -66,23 +66,20 @@ const renderNumberField = ({
}
describe('NumberField wrapper', () => {
// Group and input wrappers should preserve the design-system variants and DOM defaults.
describe('Group and input', () => {
it('should apply medium group classes by default and merge custom className', () => {
renderNumberField({
it('should apply medium group classes by default and merge custom className', async () => {
const screen = await renderNumberField({
groupProps: {
className: 'custom-group',
},
})
const group = screen.getByTestId('group')
expect(group).toHaveClass('rounded-lg')
expect(group).toHaveClass('custom-group')
await expect.element(screen.getByTestId('group')).toHaveClass('rounded-lg')
await expect.element(screen.getByTestId('group')).toHaveClass('custom-group')
})
it('should apply large group and input classes when large size is provided', () => {
renderNumberField({
it('should apply large group and input classes when large size is provided', async () => {
const screen = await renderNumberField({
groupProps: {
size: 'large',
},
@@ -91,16 +88,13 @@ describe('NumberField wrapper', () => {
},
})
const group = screen.getByTestId('group')
const input = screen.getByTestId('input')
expect(group).toHaveClass('rounded-[10px]')
expect(input).toHaveClass('px-4')
expect(input).toHaveClass('py-2')
await expect.element(screen.getByTestId('group')).toHaveClass('rounded-[10px]')
await expect.element(screen.getByTestId('input')).toHaveClass('px-4')
await expect.element(screen.getByTestId('input')).toHaveClass('py-2')
})
it('should set input defaults and forward passthrough props', () => {
renderNumberField({
it('should set input defaults and forward passthrough props', async () => {
const screen = await renderNumberField({
inputProps: {
className: 'custom-input',
placeholder: 'Regular placeholder',
@@ -108,26 +102,23 @@ describe('NumberField wrapper', () => {
},
})
const input = screen.getByRole('textbox', { name: 'Amount' })
expect(input).toHaveAttribute('autoComplete', 'off')
expect(input).toHaveAttribute('autoCorrect', 'off')
expect(input).toHaveAttribute('placeholder', 'Regular placeholder')
expect(input).toBeRequired()
expect(input).toHaveClass('px-3')
expect(input).toHaveClass('py-[7px]')
expect(input).toHaveClass('system-sm-regular')
expect(input).toHaveClass('custom-input')
await expect.element(screen.getByRole('textbox', { name: 'Amount' })).toHaveAttribute('autocomplete', 'off')
await expect.element(screen.getByRole('textbox', { name: 'Amount' })).toHaveAttribute('autocorrect', 'off')
await expect.element(screen.getByRole('textbox', { name: 'Amount' })).toHaveAttribute('placeholder', 'Regular placeholder')
await expect.element(screen.getByRole('textbox', { name: 'Amount' })).toBeRequired()
await expect.element(screen.getByRole('textbox', { name: 'Amount' })).toHaveClass('px-3')
await expect.element(screen.getByRole('textbox', { name: 'Amount' })).toHaveClass('py-[7px]')
await expect.element(screen.getByRole('textbox', { name: 'Amount' })).toHaveClass('system-sm-regular')
await expect.element(screen.getByRole('textbox', { name: 'Amount' })).toHaveClass('custom-input')
})
})
// Unit and controls wrappers should preserve layout tokens and HTML passthrough props.
describe('Unit and controls', () => {
it.each([
['medium', 'pr-2'],
['large', 'pr-2.5'],
] as const)('should apply the %s unit spacing variant', (size, spacingClass) => {
renderNumberField({
] as const)('should apply the %s unit spacing variant', async (size, spacingClass) => {
const screen = await renderNumberField({
unitProps: {
size,
className: 'custom-unit',
@@ -135,45 +126,37 @@ describe('NumberField wrapper', () => {
},
})
const unit = screen.getByTestId('unit')
expect(unit).toHaveTextContent('ms')
expect(unit).toHaveAttribute('title', `unit-${size}`)
expect(unit).toHaveClass('custom-unit')
expect(unit).toHaveClass(spacingClass)
await expect.element(screen.getByTestId('unit')).toHaveTextContent('ms')
await expect.element(screen.getByTestId('unit')).toHaveAttribute('title', `unit-${size}`)
await expect.element(screen.getByTestId('unit')).toHaveClass('custom-unit')
await expect.element(screen.getByTestId('unit')).toHaveClass(spacingClass)
})
it('should forward passthrough props to controls', () => {
renderNumberField({
it('should forward passthrough props to controls', async () => {
const screen = await renderNumberField({
controlsProps: {
className: 'custom-controls',
title: 'controls-title',
},
})
const controls = screen.getByTestId('controls')
expect(controls).toHaveAttribute('title', 'controls-title')
expect(controls).toHaveClass('custom-controls')
await expect.element(screen.getByTestId('controls')).toHaveAttribute('title', 'controls-title')
await expect.element(screen.getByTestId('controls')).toHaveClass('custom-controls')
})
})
// Increment and decrement buttons should preserve accessible naming, icon fallbacks, and spacing variants.
describe('Control buttons', () => {
it('should provide english fallback aria labels and default icons when labels are not provided', () => {
renderNumberField({
it('should provide english fallback aria labels and default icons when labels are not provided', async () => {
const screen = await renderNumberField({
controlsProps: {},
})
const increment = screen.getByRole('button', { name: 'Increment value' })
const decrement = screen.getByRole('button', { name: 'Decrement value' })
expect(increment.querySelector('.i-ri-arrow-up-s-line')).toBeInTheDocument()
expect(decrement.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Increment value' }).element().querySelector('.i-ri-arrow-up-s-line')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Decrement value' }).element().querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
})
it('should preserve explicit aria labels and custom children', () => {
renderNumberField({
it('should preserve explicit aria labels and custom children', async () => {
const screen = await renderNumberField({
controlsProps: {},
incrementProps: {
'aria-label': 'Increase amount',
@@ -185,17 +168,14 @@ describe('NumberField wrapper', () => {
},
})
const increment = screen.getByRole('button', { name: 'Increase amount' })
const decrement = screen.getByRole('button', { name: 'Decrease amount' })
expect(increment).toContainElement(screen.getByTestId('custom-increment-icon'))
expect(decrement).toContainElement(screen.getByTestId('custom-decrement-icon'))
expect(increment.querySelector('.i-ri-arrow-up-s-line')).not.toBeInTheDocument()
expect(decrement.querySelector('.i-ri-arrow-down-s-line')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Increase amount' }).element()).toContainElement(screen.getByTestId('custom-increment-icon').element())
expect(screen.getByRole('button', { name: 'Decrease amount' }).element()).toContainElement(screen.getByTestId('custom-decrement-icon').element())
expect(screen.getByRole('button', { name: 'Increase amount' }).element().querySelector('.i-ri-arrow-up-s-line')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Decrease amount' }).element().querySelector('.i-ri-arrow-down-s-line')).not.toBeInTheDocument()
})
it('should keep the fallback aria labels when aria-label is omitted in props', () => {
renderNumberField({
it('should keep the fallback aria labels when aria-label is omitted in props', async () => {
const screen = await renderNumberField({
controlsProps: {},
incrementProps: {
'aria-label': undefined,
@@ -205,12 +185,12 @@ describe('NumberField wrapper', () => {
},
})
expect(screen.getByRole('button', { name: 'Increment value' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Decrement value' })).toBeInTheDocument()
await expect.element(screen.getByRole('button', { name: 'Increment value' })).toBeInTheDocument()
await expect.element(screen.getByRole('button', { name: 'Decrement value' })).toBeInTheDocument()
})
it('should rely on aria-labelledby when provided instead of injecting a fallback aria-label', () => {
render(
it('should rely on aria-labelledby when provided instead of injecting a fallback aria-label', async () => {
const screen = await render(
<>
<span id="increment-label">Increment from label</span>
<span id="decrement-label">Decrement from label</span>
@@ -226,18 +206,15 @@ describe('NumberField wrapper', () => {
</>,
)
const increment = screen.getByRole('button', { name: 'Increment from label' })
const decrement = screen.getByRole('button', { name: 'Decrement from label' })
expect(increment).not.toHaveAttribute('aria-label')
expect(decrement).not.toHaveAttribute('aria-label')
await expect.element(screen.getByRole('button', { name: 'Increment from label' })).not.toHaveAttribute('aria-label')
await expect.element(screen.getByRole('button', { name: 'Decrement from label' })).not.toHaveAttribute('aria-label')
})
it.each([
['medium', 'pt-1', 'pb-1'],
['large', 'pt-1.5', 'pb-1.5'],
] as const)('should apply the %s control button compound spacing classes', (size, incrementClass, decrementClass) => {
renderNumberField({
] as const)('should apply the %s control button compound spacing classes', async (size, incrementClass, decrementClass) => {
const screen = await renderNumberField({
controlsProps: {},
incrementProps: {
size,
@@ -250,14 +227,11 @@ describe('NumberField wrapper', () => {
},
})
const increment = screen.getByTestId('increment')
const decrement = screen.getByTestId('decrement')
expect(increment).toHaveClass(incrementClass)
expect(increment).toHaveClass('custom-increment')
expect(decrement).toHaveClass(decrementClass)
expect(decrement).toHaveClass('custom-decrement')
expect(decrement).toHaveAttribute('title', `decrement-${size}`)
await expect.element(screen.getByTestId('increment')).toHaveClass(incrementClass)
await expect.element(screen.getByTestId('increment')).toHaveClass('custom-increment')
await expect.element(screen.getByTestId('decrement')).toHaveClass(decrementClass)
await expect.element(screen.getByTestId('decrement')).toHaveClass('custom-decrement')
await expect.element(screen.getByTestId('decrement')).toHaveAttribute('title', `decrement-${size}`)
})
})
})

View File

@@ -1,15 +1,20 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-react'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '..'
const renderWithSafeViewport = (ui: import('react').ReactNode) => render(
<div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}>
{ui}
</div>,
)
describe('PopoverContent', () => {
describe('Placement', () => {
it('should use bottom placement and default offsets when placement props are not provided', () => {
render(
it('should use bottom placement and default offsets when placement props are not provided', async () => {
const screen = await renderWithSafeViewport(
<Popover open>
<PopoverTrigger aria-label="popover trigger">Open</PopoverTrigger>
<PopoverContent
@@ -21,16 +26,13 @@ describe('PopoverContent', () => {
</Popover>,
)
const positioner = screen.getByRole('group', { name: 'default positioner' })
const popup = screen.getByRole('dialog', { name: 'default popover' })
expect(positioner).toHaveAttribute('data-side', 'bottom')
expect(positioner).toHaveAttribute('data-align', 'center')
expect(popup).toHaveTextContent('Default content')
await expect.element(screen.getByRole('group', { name: 'default positioner' })).toHaveAttribute('data-side', 'bottom')
await expect.element(screen.getByRole('group', { name: 'default positioner' })).toHaveAttribute('data-align', 'center')
await expect.element(screen.getByRole('dialog', { name: 'default popover' })).toHaveTextContent('Default content')
})
it('should apply parsed custom placement and custom offsets when placement props are provided', () => {
render(
it('should apply parsed custom placement and custom offsets when placement props are provided', async () => {
const screen = await renderWithSafeViewport(
<Popover open>
<PopoverTrigger aria-label="popover trigger">Open</PopoverTrigger>
<PopoverContent
@@ -45,20 +47,17 @@ describe('PopoverContent', () => {
</Popover>,
)
const positioner = screen.getByRole('group', { name: 'custom positioner' })
const popup = screen.getByRole('dialog', { name: 'custom popover' })
expect(positioner).toHaveAttribute('data-side', 'top')
expect(positioner).toHaveAttribute('data-align', 'end')
expect(popup).toHaveTextContent('Custom placement content')
await expect.element(screen.getByRole('group', { name: 'custom positioner' })).toHaveAttribute('data-side', 'top')
await expect.element(screen.getByRole('group', { name: 'custom positioner' })).toHaveAttribute('data-align', 'end')
await expect.element(screen.getByRole('dialog', { name: 'custom popover' })).toHaveTextContent('Custom placement content')
})
})
describe('Passthrough props', () => {
it('should forward positionerProps and popupProps when passthrough props are provided', () => {
it('should forward positionerProps and popupProps when passthrough props are provided', async () => {
const onPopupClick = vi.fn()
render(
const screen = await render(
<Popover open>
<PopoverTrigger aria-label="popover trigger">Open</PopoverTrigger>
<PopoverContent
@@ -79,12 +78,11 @@ describe('PopoverContent', () => {
</Popover>,
)
const positioner = screen.getByRole('group', { name: 'popover positioner' })
const popup = screen.getByRole('dialog', { name: 'popover content' })
fireEvent.click(popup)
await popup.click()
expect(positioner).toHaveAttribute('id', 'popover-positioner-id')
expect(popup).toHaveAttribute('id', 'popover-popup-id')
await expect.element(screen.getByRole('group', { name: 'popover positioner' })).toHaveAttribute('id', 'popover-positioner-id')
await expect.element(popup).toHaveAttribute('id', 'popover-popup-id')
expect(onPopupClick).toHaveBeenCalledTimes(1)
})
})

View File

@@ -1,5 +1,4 @@
import { render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { render } from 'vitest-browser-react'
import {
ScrollArea,
ScrollAreaContent,
@@ -10,6 +9,28 @@ import {
ScrollAreaViewport,
} from '../index'
const stubElementMetric = (
element: HTMLElement,
property: 'clientHeight' | 'clientWidth' | 'scrollHeight' | 'scrollWidth',
value: number,
) => {
const originalDescriptor = Object.getOwnPropertyDescriptor(element, property)
Object.defineProperty(element, property, {
configurable: true,
get: () => value,
})
return () => {
if (originalDescriptor) {
Object.defineProperty(element, property, originalDescriptor)
return
}
delete (element as Partial<Record<typeof property, number>>)[property]
}
}
const renderScrollArea = (options: {
rootClassName?: string
viewportClassName?: string
@@ -50,21 +71,19 @@ const renderScrollArea = (options: {
describe('scroll-area wrapper', () => {
describe('Rendering', () => {
it('should render the compound exports together', async () => {
renderScrollArea()
const screen = await renderScrollArea()
await waitFor(() => {
expect(screen.getByTestId('scroll-area-root')).toBeInTheDocument()
expect(screen.getByTestId('scroll-area-viewport')).toBeInTheDocument()
expect(screen.getByTestId('scroll-area-content')).toHaveTextContent('Scrollable content')
expect(screen.getByTestId('scroll-area-vertical-scrollbar')).toBeInTheDocument()
expect(screen.getByTestId('scroll-area-vertical-thumb')).toBeInTheDocument()
expect(screen.getByTestId('scroll-area-horizontal-scrollbar')).toBeInTheDocument()
expect(screen.getByTestId('scroll-area-horizontal-thumb')).toBeInTheDocument()
})
await expect.element(screen.getByTestId('scroll-area-root')).toBeInTheDocument()
await expect.element(screen.getByTestId('scroll-area-viewport')).toBeInTheDocument()
await expect.element(screen.getByTestId('scroll-area-content')).toHaveTextContent('Scrollable content')
await expect.element(screen.getByTestId('scroll-area-vertical-scrollbar')).toBeInTheDocument()
await expect.element(screen.getByTestId('scroll-area-vertical-thumb')).toBeInTheDocument()
await expect.element(screen.getByTestId('scroll-area-horizontal-scrollbar')).toBeInTheDocument()
await expect.element(screen.getByTestId('scroll-area-horizontal-thumb')).toBeInTheDocument()
})
it('should render the convenience wrapper and apply slot props', async () => {
render(
const screen = await render(
<>
<p id="installed-apps-label">Installed apps</p>
<ScrollArea
@@ -82,31 +101,24 @@ describe('scroll-area wrapper', () => {
</>,
)
await waitFor(() => {
const root = screen.getByTestId('scroll-area-wrapper-root')
const viewport = screen.getByRole('region', { name: 'Installed apps' })
const content = screen.getByText('Scrollable content').parentElement
const content = screen.getByText('Scrollable content').element().parentElement
expect(root).toBeInTheDocument()
expect(viewport).toHaveClass('custom-viewport-class')
expect(viewport).toHaveAccessibleName('Installed apps')
await expect.element(screen.getByTestId('scroll-area-wrapper-root')).toBeInTheDocument()
await expect.element(viewport).toHaveClass('custom-viewport-class')
await expect.element(viewport).toHaveAccessibleName('Installed apps')
expect(content).toHaveClass('custom-content-class')
expect(screen.getByText('Scrollable content')).toBeInTheDocument()
})
await expect.element(screen.getByText('Scrollable content')).toBeInTheDocument()
})
})
describe('Scrollbar', () => {
it('should apply the default vertical scrollbar classes and orientation data attribute', async () => {
renderScrollArea()
const screen = await renderScrollArea()
await waitFor(() => {
const scrollbar = screen.getByTestId('scroll-area-vertical-scrollbar')
const thumb = screen.getByTestId('scroll-area-vertical-thumb')
expect(scrollbar).toHaveAttribute('data-orientation', 'vertical')
expect(scrollbar).toHaveAttribute('data-dify-scrollbar')
expect(scrollbar).toHaveClass(
await expect.element(screen.getByTestId('scroll-area-vertical-scrollbar')).toHaveAttribute('data-orientation', 'vertical')
await expect.element(screen.getByTestId('scroll-area-vertical-scrollbar')).toHaveAttribute('data-dify-scrollbar')
await expect.element(screen.getByTestId('scroll-area-vertical-scrollbar')).toHaveClass(
'flex',
'overflow-clip',
'p-1',
@@ -123,8 +135,8 @@ describe('scroll-area wrapper', () => {
'data-[orientation=vertical]:w-3',
'data-[orientation=vertical]:justify-center',
)
expect(thumb).toHaveAttribute('data-orientation', 'vertical')
expect(thumb).toHaveClass(
await expect.element(screen.getByTestId('scroll-area-vertical-thumb')).toHaveAttribute('data-orientation', 'vertical')
await expect.element(screen.getByTestId('scroll-area-vertical-thumb')).toHaveClass(
'shrink-0',
'rounded-sm',
'bg-state-base-handle',
@@ -133,18 +145,13 @@ describe('scroll-area wrapper', () => {
'data-[orientation=vertical]:w-1',
)
})
})
it('should apply horizontal scrollbar and thumb classes when orientation is horizontal', async () => {
renderScrollArea()
const screen = await renderScrollArea()
await waitFor(() => {
const scrollbar = screen.getByTestId('scroll-area-horizontal-scrollbar')
const thumb = screen.getByTestId('scroll-area-horizontal-thumb')
expect(scrollbar).toHaveAttribute('data-orientation', 'horizontal')
expect(scrollbar).toHaveAttribute('data-dify-scrollbar')
expect(scrollbar).toHaveClass(
await expect.element(screen.getByTestId('scroll-area-horizontal-scrollbar')).toHaveAttribute('data-orientation', 'horizontal')
await expect.element(screen.getByTestId('scroll-area-horizontal-scrollbar')).toHaveAttribute('data-dify-scrollbar')
await expect.element(screen.getByTestId('scroll-area-horizontal-scrollbar')).toHaveClass(
'flex',
'overflow-clip',
'p-1',
@@ -161,8 +168,8 @@ describe('scroll-area wrapper', () => {
'data-[orientation=horizontal]:h-3',
'data-[orientation=horizontal]:items-center',
)
expect(thumb).toHaveAttribute('data-orientation', 'horizontal')
expect(thumb).toHaveClass(
await expect.element(screen.getByTestId('scroll-area-horizontal-thumb')).toHaveAttribute('data-orientation', 'horizontal')
await expect.element(screen.getByTestId('scroll-area-horizontal-thumb')).toHaveClass(
'shrink-0',
'rounded-sm',
'bg-state-base-handle',
@@ -172,16 +179,14 @@ describe('scroll-area wrapper', () => {
)
})
})
})
describe('Props', () => {
it('should forward className to the viewport', async () => {
renderScrollArea({
const screen = await renderScrollArea({
viewportClassName: 'custom-viewport-class',
})
await waitFor(() => {
expect(screen.getByTestId('scroll-area-viewport')).toHaveClass(
await expect.element(screen.getByTestId('scroll-area-viewport')).toHaveClass(
'size-full',
'min-h-0',
'min-w-0',
@@ -192,67 +197,45 @@ describe('scroll-area wrapper', () => {
'custom-viewport-class',
)
})
})
it('should let callers control scrollbar inset spacing via margin-based className overrides', async () => {
renderScrollArea({
const screen = await renderScrollArea({
verticalScrollbarClassName: 'data-[orientation=vertical]:my-2 data-[orientation=vertical]:-me-3',
horizontalScrollbarClassName: 'data-[orientation=horizontal]:mx-2 data-[orientation=horizontal]:mb-2',
})
await waitFor(() => {
expect(screen.getByTestId('scroll-area-vertical-scrollbar')).toHaveClass(
await expect.element(screen.getByTestId('scroll-area-vertical-scrollbar')).toHaveClass(
'data-[orientation=vertical]:my-2',
'data-[orientation=vertical]:-me-3',
)
expect(screen.getByTestId('scroll-area-horizontal-scrollbar')).toHaveClass(
await expect.element(screen.getByTestId('scroll-area-horizontal-scrollbar')).toHaveClass(
'data-[orientation=horizontal]:mx-2',
'data-[orientation=horizontal]:mb-2',
)
})
})
})
describe('Corner', () => {
it('should render the corner export when both axes overflow', async () => {
const originalDescriptors = {
clientHeight: Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, 'clientHeight'),
clientWidth: Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, 'clientWidth'),
scrollHeight: Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, 'scrollHeight'),
scrollWidth: Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, 'scrollWidth'),
}
Object.defineProperties(HTMLDivElement.prototype, {
clientHeight: {
configurable: true,
get() {
return this.getAttribute('data-testid') === 'scroll-area-viewport' ? 80 : 0
},
},
clientWidth: {
configurable: true,
get() {
return this.getAttribute('data-testid') === 'scroll-area-viewport' ? 80 : 0
},
},
scrollHeight: {
configurable: true,
get() {
return this.getAttribute('data-testid') === 'scroll-area-viewport' ? 160 : 0
},
},
scrollWidth: {
configurable: true,
get() {
return this.getAttribute('data-testid') === 'scroll-area-viewport' ? 160 : 0
},
},
})
const restoreViewportMetrics: Array<() => void> = []
try {
render(
const screen = await render(
<ScrollAreaRoot className="h-40 w-40" data-testid="scroll-area-root">
<ScrollAreaViewport data-testid="scroll-area-viewport">
<ScrollAreaViewport
data-testid="scroll-area-viewport"
ref={(node) => {
if (!node || restoreViewportMetrics.length > 0)
return
restoreViewportMetrics.push(
stubElementMetric(node, 'clientHeight', 80),
stubElementMetric(node, 'clientWidth', 80),
stubElementMetric(node, 'scrollHeight', 160),
stubElementMetric(node, 'scrollWidth', 160),
)
}}
>
<ScrollAreaContent data-testid="scroll-area-content">
<div className="h-48 w-48">Scrollable content</div>
</ScrollAreaContent>
@@ -271,24 +254,13 @@ describe('scroll-area wrapper', () => {
</ScrollAreaRoot>,
)
await waitFor(() => {
expect(screen.getByTestId('scroll-area-corner')).toBeInTheDocument()
expect(screen.getByTestId('scroll-area-corner')).toHaveClass('bg-transparent')
await vi.waitFor(() => {
expect(screen.getByTestId('scroll-area-corner').element()).toBeInTheDocument()
expect(screen.getByTestId('scroll-area-corner').element()).toHaveClass('bg-transparent')
})
}
finally {
if (originalDescriptors.clientHeight) {
Object.defineProperty(HTMLDivElement.prototype, 'clientHeight', originalDescriptors.clientHeight)
}
if (originalDescriptors.clientWidth) {
Object.defineProperty(HTMLDivElement.prototype, 'clientWidth', originalDescriptors.clientWidth)
}
if (originalDescriptors.scrollHeight) {
Object.defineProperty(HTMLDivElement.prototype, 'scrollHeight', originalDescriptors.scrollHeight)
}
if (originalDescriptors.scrollWidth) {
Object.defineProperty(HTMLDivElement.prototype, 'scrollWidth', originalDescriptors.scrollWidth)
}
restoreViewportMetrics.splice(0).forEach(restore => restore())
}
})
})

View File

@@ -1,7 +1,13 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-react'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
const renderWithSafeViewport = (ui: import('react').ReactNode) => render(
<div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}>
{ui}
</div>,
)
const renderOpenSelect = ({
rootProps = {},
triggerProps = {},
@@ -13,7 +19,7 @@ const renderOpenSelect = ({
contentProps?: Record<string, unknown>
onValueChange?: (value: string | null) => void
} = {}) => {
return render(
return renderWithSafeViewport(
<Select open defaultValue="seattle" onValueChange={onValueChange} {...rootProps}>
<SelectTrigger aria-label="city select" {...triggerProps}>
<SelectValue />
@@ -48,8 +54,8 @@ const renderOpenSelect = ({
describe('Select wrappers', () => {
describe('Select root integration', () => {
it('should submit the hidden input value and preserve autocomplete hints inside a form', () => {
const { container } = render(
it('should submit the hidden input value and preserve autocomplete hints inside a form', async () => {
const screen = await render(
<form aria-label="profile form">
<Select defaultValue="seattle" name="city" autoComplete="address-level2">
<SelectTrigger aria-label="city select">
@@ -69,8 +75,8 @@ describe('Select wrappers', () => {
</form>,
)
const hiddenInput = container.querySelector('input[name="city"]')
const form = screen.getByRole('form', { name: 'profile form' }) as HTMLFormElement
const hiddenInput = screen.container.querySelector('input[name="city"]')
const form = screen.getByRole('form', { name: 'profile form' }).element() as HTMLFormElement
expect(hiddenInput).toHaveAttribute('autocomplete', 'address-level2')
expect(new FormData(form).get('city')).toBe('seattle')
@@ -78,119 +84,104 @@ describe('Select wrappers', () => {
})
describe('SelectTrigger', () => {
it('should forward native trigger props when trigger props are provided', () => {
renderOpenSelect({
it('should forward native trigger props when trigger props are provided', async () => {
const screen = await renderOpenSelect({
triggerProps: {
'aria-label': 'Choose option',
'disabled': true,
},
})
const trigger = screen.getByRole('combobox', { name: 'Choose option' })
expect(trigger).toBeDisabled()
await expect.element(screen.getByRole('combobox', { name: 'Choose option' })).toBeDisabled()
})
it('should apply regular size variant classes by default', () => {
renderOpenSelect()
it('should apply regular size variant classes by default', async () => {
const screen = await renderOpenSelect()
const trigger = screen.getByRole('combobox', { name: 'city select' })
expect(trigger.className).toMatch(/system-sm-regular/)
expect(trigger.className).toMatch(/rounded-lg/)
expect(screen.getByRole('combobox', { name: 'city select' }).element().className).toMatch(/system-sm-regular/)
expect(screen.getByRole('combobox', { name: 'city select' }).element().className).toMatch(/rounded-lg/)
})
it('should apply small size variant classes when size is small', () => {
renderOpenSelect({
it('should apply small size variant classes when size is small', async () => {
const screen = await renderOpenSelect({
triggerProps: { size: 'small' },
})
const trigger = screen.getByRole('combobox', { name: 'city select' })
expect(trigger.className).toMatch(/system-xs-regular/)
expect(trigger.className).toMatch(/rounded-md/)
expect(screen.getByRole('combobox', { name: 'city select' }).element().className).toMatch(/system-xs-regular/)
expect(screen.getByRole('combobox', { name: 'city select' }).element().className).toMatch(/rounded-md/)
})
it('should apply large size variant classes when size is large', () => {
renderOpenSelect({
it('should apply large size variant classes when size is large', async () => {
const screen = await renderOpenSelect({
triggerProps: { size: 'large' },
})
const trigger = screen.getByRole('combobox', { name: 'city select' })
expect(trigger.className).toMatch(/system-md-regular/)
expect(screen.getByRole('combobox', { name: 'city select' }).element().className).toMatch(/system-md-regular/)
})
it('should apply disabled styling via data attributes when disabled', () => {
renderOpenSelect({
it('should apply disabled styling via data attributes when disabled', async () => {
const screen = await renderOpenSelect({
triggerProps: { disabled: true },
})
const trigger = screen.getByRole('combobox', { name: 'city select' })
expect(trigger).toHaveAttribute('data-disabled')
expect(trigger.className).toContain('data-disabled:bg-components-input-bg-disabled')
await expect.element(screen.getByRole('combobox', { name: 'city select' })).toHaveAttribute('data-disabled')
expect(screen.getByRole('combobox', { name: 'city select' }).element().className).toContain('data-disabled:bg-components-input-bg-disabled')
})
it('should apply disabled placeholder color class for compound state', () => {
renderOpenSelect({
it('should apply disabled placeholder color class for compound state', async () => {
const screen = await renderOpenSelect({
triggerProps: { disabled: true },
})
const trigger = screen.getByRole('combobox', { name: 'city select' })
expect(trigger.className).toContain('data-disabled:data-placeholder:text-components-input-text-disabled')
expect(screen.getByRole('combobox', { name: 'city select' }).element().className).toContain('data-disabled:data-placeholder:text-components-input-text-disabled')
})
it('should apply readonly styling via data attributes when Root is readOnly', () => {
renderOpenSelect({
it('should apply readonly styling via data attributes when Root is readOnly', async () => {
const screen = await renderOpenSelect({
rootProps: { readOnly: true },
})
const trigger = screen.getByRole('combobox', { name: 'city select' })
expect(trigger).toHaveAttribute('data-readonly')
expect(trigger.className).toContain('data-readonly:bg-transparent')
await expect.element(screen.getByRole('combobox', { name: 'city select' })).toHaveAttribute('data-readonly')
expect(screen.getByRole('combobox', { name: 'city select' }).element().className).toContain('data-readonly:bg-transparent')
})
it('should hide arrow icon via CSS when Root is readOnly', () => {
renderOpenSelect({
it('should hide arrow icon via CSS when Root is readOnly', async () => {
const screen = await renderOpenSelect({
rootProps: { readOnly: true },
})
const trigger = screen.getByRole('combobox', { name: 'city select' })
const iconWrapper = trigger.querySelector('[class*="group-data-readonly:hidden"]')
expect(iconWrapper).toBeInTheDocument()
expect(screen.getByRole('combobox', { name: 'city select' }).element().querySelector('[class*="group-data-readonly:hidden"]')).toBeInTheDocument()
})
it('should set aria-hidden on decorative icons', () => {
renderOpenSelect()
it('should set aria-hidden on decorative icons', async () => {
const screen = await renderOpenSelect()
const trigger = screen.getByRole('combobox', { name: 'city select' })
const arrowIcon = trigger.querySelector('.i-ri-arrow-down-s-line')
expect(arrowIcon).toHaveAttribute('aria-hidden', 'true')
expect(screen.getByRole('combobox', { name: 'city select' }).element().querySelector('.i-ri-arrow-down-s-line')).toHaveAttribute('aria-hidden', 'true')
})
it('should include placeholder color class via data attribute', () => {
renderOpenSelect()
it('should include placeholder color class via data attribute', async () => {
const screen = await renderOpenSelect()
const trigger = screen.getByRole('combobox', { name: 'city select' })
expect(trigger.className).toContain('data-placeholder:text-components-input-text-placeholder')
expect(screen.getByRole('combobox', { name: 'city select' }).element().className).toContain('data-placeholder:text-components-input-text-placeholder')
})
it('should render built-in chevron icon', () => {
renderOpenSelect()
it('should render built-in chevron icon', async () => {
const screen = await renderOpenSelect()
const trigger = screen.getByRole('combobox', { name: 'city select' })
const chevron = trigger.querySelector('.i-ri-arrow-down-s-line')
expect(chevron).toBeInTheDocument()
expect(screen.getByRole('combobox', { name: 'city select' }).element().querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
})
})
describe('SelectContent', () => {
it('should use default placement when placement is not provided', () => {
renderOpenSelect()
it('should use positioning attributes when placement is not provided', async () => {
const screen = await renderOpenSelect()
const positioner = screen.getByRole('group', { name: 'select positioner' })
expect(positioner).toHaveAttribute('data-side', 'bottom')
expect(positioner).toHaveAttribute('data-align', 'start')
await expect.element(screen.getByRole('group', { name: 'select positioner' })).toHaveAttribute('data-side', 'bottom')
await expect.element(screen.getByRole('group', { name: 'select positioner' })).toHaveAttribute('data-align', 'start')
})
it('should apply custom placement when placement props are provided', () => {
renderOpenSelect({
it('should preserve positioning attributes when placement props are provided', async () => {
const screen = await renderOpenSelect({
contentProps: {
placement: 'top-end',
sideOffset: 12,
@@ -198,17 +189,16 @@ describe('Select wrappers', () => {
},
})
const positioner = screen.getByRole('group', { name: 'select positioner' })
expect(positioner).toHaveAttribute('data-side', 'top')
expect(positioner).toHaveAttribute('data-align', 'end')
await expect.element(screen.getByRole('group', { name: 'select positioner' })).toHaveAttribute('data-side', 'top')
await expect.element(screen.getByRole('group', { name: 'select positioner' })).toHaveAttribute('data-align', 'end')
})
it('should forward passthrough props to positioner popup and list when passthrough props are provided', () => {
it('should forward passthrough props to positioner popup and list when passthrough props are provided', async () => {
const onPositionerMouseEnter = vi.fn()
const onPopupClick = vi.fn()
const onListFocus = vi.fn()
render(
const screen = await render(
<Select open defaultValue="seattle">
<SelectTrigger aria-label="city select">
<SelectValue />
@@ -241,35 +231,35 @@ describe('Select wrappers', () => {
</Select>,
)
const positioner = screen.getByRole('group', { name: 'select positioner' })
const popup = screen.getByRole('dialog', { name: 'select popup' })
const list = screen.getByRole('listbox', { name: 'select list' })
screen.getByRole('group', { name: 'select positioner' }).element().dispatchEvent(new MouseEvent('mouseover', {
bubbles: true,
}))
asHTMLElement(screen.getByRole('dialog', { name: 'select popup' }).element()).click()
screen.getByRole('listbox', { name: 'select list' }).element().dispatchEvent(new FocusEvent('focusin', {
bubbles: true,
}))
fireEvent.mouseEnter(positioner)
fireEvent.click(popup)
fireEvent.focus(list)
expect(positioner).toHaveAttribute('id', 'select-positioner')
expect(popup).toHaveAttribute('id', 'select-popup')
expect(list).toHaveAttribute('id', 'select-list')
await expect.element(screen.getByRole('group', { name: 'select positioner' })).toHaveAttribute('id', 'select-positioner')
await expect.element(screen.getByRole('dialog', { name: 'select popup' })).toHaveAttribute('id', 'select-popup')
await expect.element(screen.getByRole('listbox', { name: 'select list' })).toHaveAttribute('id', 'select-list')
expect(onPositionerMouseEnter).toHaveBeenCalledTimes(1)
expect(onPopupClick).toHaveBeenCalledTimes(1)
expect(onListFocus).toHaveBeenCalledTimes(1)
expect(onListFocus).toHaveBeenCalled()
})
})
describe('SelectItem', () => {
it('should render options when children are provided', () => {
renderOpenSelect()
it('should render options when children are provided', async () => {
const screen = await renderOpenSelect()
expect(screen.getByRole('option', { name: 'Seattle' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: 'New York' })).toBeInTheDocument()
await expect.element(screen.getByRole('option', { name: 'Seattle' })).toBeInTheDocument()
await expect.element(screen.getByRole('option', { name: 'New York' })).toBeInTheDocument()
})
it('should not call onValueChange when disabled item is clicked', () => {
it('should not call onValueChange when disabled item is clicked', async () => {
const onValueChange = vi.fn()
render(
const screen = await render(
<Select open defaultValue="seattle" onValueChange={onValueChange}>
<SelectTrigger aria-label="city select">
<SelectValue />
@@ -287,13 +277,13 @@ describe('Select wrappers', () => {
</Select>,
)
fireEvent.click(screen.getByRole('option', { name: 'Disabled New York' }))
asHTMLElement(screen.getByRole('option', { name: 'Disabled New York' }).element()).click()
expect(onValueChange).not.toHaveBeenCalled()
})
it('should support custom composition with SelectItemText without indicator', () => {
render(
it('should support custom composition with SelectItemText without indicator', async () => {
const screen = await render(
<Select open defaultValue="a">
<SelectTrigger aria-label="custom select">
<SelectValue />
@@ -306,7 +296,7 @@ describe('Select wrappers', () => {
</Select>,
)
expect(screen.getByRole('option', { name: 'Custom Item' })).toBeInTheDocument()
await expect.element(screen.getByRole('option', { name: 'Custom Item' })).toBeInTheDocument()
})
})
})

View File

@@ -1,96 +1,80 @@
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-react'
import { Slider } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('Slider', () => {
const getSliderInput = () => screen.getByLabelText('Value')
it('should render with correct default ARIA limits and current value', async () => {
const screen = await render(<Slider value={50} onValueChange={vi.fn()} aria-label="Value" />)
it('should render with correct default ARIA limits and current value', () => {
render(<Slider value={50} onValueChange={vi.fn()} aria-label="Value" />)
const slider = getSliderInput()
expect(slider).toHaveAttribute('min', '0')
expect(slider).toHaveAttribute('max', '100')
expect(slider).toHaveAttribute('aria-valuenow', '50')
await expect.element(screen.getByLabelText('Value')).toHaveAttribute('min', '0')
await expect.element(screen.getByLabelText('Value')).toHaveAttribute('max', '100')
await expect.element(screen.getByLabelText('Value')).toHaveAttribute('aria-valuenow', '50')
})
it('should apply custom min, max, and step values', () => {
render(<Slider value={10} min={5} max={20} step={5} onValueChange={vi.fn()} aria-label="Value" />)
it('should apply custom min, max, and step values', async () => {
const screen = await render(<Slider value={10} min={5} max={20} step={5} onValueChange={vi.fn()} aria-label="Value" />)
const slider = getSliderInput()
expect(slider).toHaveAttribute('min', '5')
expect(slider).toHaveAttribute('max', '20')
expect(slider).toHaveAttribute('aria-valuenow', '10')
await expect.element(screen.getByLabelText('Value')).toHaveAttribute('min', '5')
await expect.element(screen.getByLabelText('Value')).toHaveAttribute('max', '20')
await expect.element(screen.getByLabelText('Value')).toHaveAttribute('aria-valuenow', '10')
})
it('should clamp non-finite values to min', () => {
render(<Slider value={Number.NaN} min={5} onValueChange={vi.fn()} aria-label="Value" />)
it('should clamp non-finite values to min', async () => {
const screen = await render(<Slider value={Number.NaN} min={5} onValueChange={vi.fn()} aria-label="Value" />)
expect(getSliderInput()).toHaveAttribute('aria-valuenow', '5')
await expect.element(screen.getByLabelText('Value')).toHaveAttribute('aria-valuenow', '5')
})
it('should call onValueChange when arrow keys are pressed', async () => {
const user = userEvent.setup()
const onValueChange = vi.fn()
const screen = await render(<Slider value={20} onValueChange={onValueChange} aria-label="Value" />)
render(<Slider value={20} onValueChange={onValueChange} aria-label="Value" />)
const slider = getSliderInput()
await act(async () => {
const slider = screen.getByLabelText('Value').element()
slider.focus()
await user.keyboard('{ArrowRight}')
})
slider.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
await vi.waitFor(() => {
expect(onValueChange).toHaveBeenCalledTimes(1)
})
expect(onValueChange).toHaveBeenLastCalledWith(21, expect.anything())
})
it('should round floating point keyboard updates to the configured step', async () => {
const user = userEvent.setup()
const onValueChange = vi.fn()
const screen = await render(<Slider value={0.2} min={0} max={1} step={0.1} onValueChange={onValueChange} aria-label="Value" />)
render(<Slider value={0.2} min={0} max={1} step={0.1} onValueChange={onValueChange} aria-label="Value" />)
const slider = getSliderInput()
await act(async () => {
const slider = screen.getByLabelText('Value').element()
slider.focus()
await user.keyboard('{ArrowRight}')
})
slider.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
await vi.waitFor(() => {
expect(onValueChange).toHaveBeenCalledTimes(1)
})
expect(onValueChange).toHaveBeenLastCalledWith(0.3, expect.anything())
})
it('should not trigger onValueChange when disabled', async () => {
const user = userEvent.setup()
const onValueChange = vi.fn()
render(<Slider value={20} onValueChange={onValueChange} disabled aria-label="Value" />)
const slider = getSliderInput()
const screen = await render(<Slider value={20} onValueChange={onValueChange} disabled aria-label="Value" />)
const slider = screen.getByLabelText('Value').element()
expect(slider).toBeDisabled()
await act(async () => {
slider.focus()
await user.keyboard('{ArrowRight}')
})
asHTMLElement(slider).click()
expect(onValueChange).not.toHaveBeenCalled()
})
it('should apply custom class names on root', () => {
const { container } = render(<Slider value={10} onValueChange={vi.fn()} className="outer-test" aria-label="Value" />)
it('should apply custom class names on root', async () => {
const screen = await render(<Slider value={10} onValueChange={vi.fn()} className="outer-test" aria-label="Value" />)
const sliderWrapper = container.querySelector('.outer-test')
expect(sliderWrapper).toBeInTheDocument()
expect(screen.container.querySelector('.outer-test')).toBeInTheDocument()
})
it('should not render prehydration script tags', () => {
const { container } = render(<Slider value={10} onValueChange={vi.fn()} aria-label="Value" />)
it('should not render prehydration script tags', async () => {
const screen = await render(<Slider value={10} onValueChange={vi.fn()} aria-label="Value" />)
expect(container.querySelector('script')).not.toBeInTheDocument()
expect(screen.container.querySelector('script')).not.toBeInTheDocument()
})
})

View File

@@ -1,198 +1,189 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-react'
import { Switch, SwitchSkeleton } from '../index'
const getThumb = (switchElement: HTMLElement) => switchElement.querySelector('span')
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
const getThumb = (switchElement: HTMLElement | SVGElement) => switchElement.querySelector('span')
describe('Switch', () => {
it('should render in unchecked state when checked is false', () => {
render(<Switch checked={false} />)
it('should render in unchecked state when checked is false', async () => {
const screen = await render(<Switch checked={false} />)
const switchElement = screen.getByRole('switch')
expect(switchElement).toBeInTheDocument()
expect(switchElement).toHaveAttribute('aria-checked', 'false')
expect(switchElement).not.toHaveAttribute('data-checked')
await expect.element(switchElement).toBeInTheDocument()
await expect.element(switchElement).toHaveAttribute('aria-checked', 'false')
await expect.element(switchElement).not.toHaveAttribute('data-checked')
})
it('should render in checked state when checked is true', () => {
render(<Switch checked={true} />)
it('should render in checked state when checked is true', async () => {
const screen = await render(<Switch checked={true} />)
const switchElement = screen.getByRole('switch')
expect(switchElement).toHaveAttribute('aria-checked', 'true')
expect(switchElement).toHaveAttribute('data-checked', '')
await expect.element(switchElement).toHaveAttribute('aria-checked', 'true')
await expect.element(switchElement).toHaveAttribute('data-checked', '')
})
it('should call onCheckedChange with next value when clicked', async () => {
const onCheckedChange = vi.fn()
const user = userEvent.setup()
render(<Switch checked={false} onCheckedChange={onCheckedChange} />)
const screen = await render(<Switch checked={false} onCheckedChange={onCheckedChange} />)
const switchElement = screen.getByRole('switch')
asHTMLElement(switchElement.element()).click()
await user.click(switchElement)
expect(onCheckedChange).toHaveBeenCalledWith(true)
expect(onCheckedChange).toHaveBeenCalledTimes(1)
expect(switchElement).toHaveAttribute('aria-checked', 'false')
await expect.element(switchElement).toHaveAttribute('aria-checked', 'false')
})
it('should work in controlled mode with checked prop', async () => {
const onCheckedChange = vi.fn()
const user = userEvent.setup()
const { rerender } = render(<Switch checked={false} onCheckedChange={onCheckedChange} />)
const screen = await render(<Switch checked={false} onCheckedChange={onCheckedChange} />)
const switchElement = screen.getByRole('switch')
expect(switchElement).toHaveAttribute('aria-checked', 'false')
await expect.element(switchElement).toHaveAttribute('aria-checked', 'false')
await user.click(switchElement)
asHTMLElement(switchElement.element()).click()
expect(onCheckedChange).toHaveBeenCalledWith(true)
expect(switchElement).toHaveAttribute('aria-checked', 'false')
await expect.element(switchElement).toHaveAttribute('aria-checked', 'false')
rerender(<Switch checked={true} onCheckedChange={onCheckedChange} />)
expect(switchElement).toHaveAttribute('aria-checked', 'true')
await screen.rerender(<Switch checked={true} onCheckedChange={onCheckedChange} />)
await expect.element(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
})
it('should not call onCheckedChange when disabled', async () => {
const onCheckedChange = vi.fn()
const user = userEvent.setup()
render(<Switch checked={false} disabled onCheckedChange={onCheckedChange} />)
const screen = await render(<Switch checked={false} disabled onCheckedChange={onCheckedChange} />)
const switchElement = screen.getByRole('switch')
expect(switchElement).toHaveClass('data-disabled:cursor-not-allowed')
expect(switchElement).toHaveAttribute('data-disabled', '')
await expect.element(switchElement).toHaveClass('data-disabled:cursor-not-allowed')
await expect.element(switchElement).toHaveAttribute('data-disabled', '')
await user.click(switchElement)
asHTMLElement(switchElement.element()).click()
expect(onCheckedChange).not.toHaveBeenCalled()
})
it('should apply correct size classes', () => {
const { rerender } = render(<Switch checked={false} size="xs" />)
const switchElement = screen.getByRole('switch')
expect(switchElement).toHaveClass('h-2.5', 'w-3.5', 'rounded-xs')
it('should apply correct size classes', async () => {
const screen = await render(<Switch checked={false} size="xs" />)
rerender(<Switch checked={false} size="sm" />)
expect(switchElement).toHaveClass('h-3', 'w-5')
await expect.element(screen.getByRole('switch')).toHaveClass('h-2.5', 'w-3.5', 'rounded-xs')
rerender(<Switch checked={false} size="md" />)
expect(switchElement).toHaveClass('h-4', 'w-7')
await screen.rerender(<Switch checked={false} size="sm" />)
await expect.element(screen.getByRole('switch')).toHaveClass('h-3', 'w-5')
rerender(<Switch checked={false} size="lg" />)
expect(switchElement).toHaveClass('h-5', 'w-9')
await screen.rerender(<Switch checked={false} size="md" />)
await expect.element(screen.getByRole('switch')).toHaveClass('h-4', 'w-7')
await screen.rerender(<Switch checked={false} size="lg" />)
await expect.element(screen.getByRole('switch')).toHaveClass('h-5', 'w-9')
})
it('should apply custom className', () => {
render(<Switch checked={false} className="custom-test-class" />)
expect(screen.getByRole('switch')).toHaveClass('custom-test-class')
it('should apply custom className', async () => {
const screen = await render(<Switch checked={false} className="custom-test-class" />)
await expect.element(screen.getByRole('switch')).toHaveClass('custom-test-class')
})
it('should expose checked state styling hooks on the root and thumb', () => {
const { rerender } = render(<Switch checked={false} />)
const switchElement = screen.getByRole('switch')
it('should expose checked state styling hooks on the root and thumb', async () => {
const screen = await render(<Switch checked={false} />)
const switchElement = screen.getByRole('switch').element()
const thumb = getThumb(switchElement)
expect(switchElement).toHaveClass('bg-components-toggle-bg-unchecked', 'data-checked:bg-components-toggle-bg')
expect(thumb).toHaveClass('data-checked:translate-x-[14px]')
expect(thumb).not.toHaveAttribute('data-checked')
rerender(<Switch checked={true} />)
expect(switchElement).toHaveAttribute('data-checked', '')
expect(thumb).toHaveAttribute('data-checked', '')
await screen.rerender(<Switch checked={true} />)
await expect.element(screen.getByRole('switch')).toHaveAttribute('data-checked', '')
expect(getThumb(screen.getByRole('switch').element())).toHaveAttribute('data-checked', '')
})
it('should expose disabled state styling hooks instead of relying on opacity', () => {
const { rerender } = render(<Switch checked={false} disabled />)
const switchElement = screen.getByRole('switch')
it('should expose disabled state styling hooks instead of relying on opacity', async () => {
const screen = await render(<Switch checked={false} disabled />)
expect(switchElement).toHaveClass(
await expect.element(screen.getByRole('switch')).toHaveClass(
'data-disabled:bg-components-toggle-bg-unchecked-disabled',
'data-disabled:data-checked:bg-components-toggle-bg-disabled',
)
expect(switchElement).toHaveAttribute('data-disabled', '')
await expect.element(screen.getByRole('switch')).toHaveAttribute('data-disabled', '')
rerender(<Switch checked={true} disabled />)
expect(switchElement).toHaveAttribute('data-disabled', '')
expect(switchElement).toHaveAttribute('data-checked', '')
await screen.rerender(<Switch checked={true} disabled />)
await expect.element(screen.getByRole('switch')).toHaveAttribute('data-disabled', '')
await expect.element(screen.getByRole('switch')).toHaveAttribute('data-checked', '')
})
it('should have focus-visible ring-3 styles', () => {
render(<Switch checked={false} />)
const switchElement = screen.getByRole('switch')
expect(switchElement).toHaveClass('focus-visible:ring-2')
it('should have focus-visible ring-3 styles', async () => {
const screen = await render(<Switch checked={false} />)
await expect.element(screen.getByRole('switch')).toHaveClass('focus-visible:ring-2')
})
it('should respect prefers-reduced-motion', () => {
render(<Switch checked={false} />)
const switchElement = screen.getByRole('switch')
expect(switchElement).toHaveClass('motion-reduce:transition-none')
it('should respect prefers-reduced-motion', async () => {
const screen = await render(<Switch checked={false} />)
await expect.element(screen.getByRole('switch')).toHaveClass('motion-reduce:transition-none')
})
describe('loading state', () => {
it('should render as disabled when loading', async () => {
const onCheckedChange = vi.fn()
const user = userEvent.setup()
render(<Switch checked={false} loading onCheckedChange={onCheckedChange} />)
const screen = await render(<Switch checked={false} loading onCheckedChange={onCheckedChange} />)
const switchElement = screen.getByRole('switch')
expect(switchElement).toHaveClass('data-disabled:cursor-not-allowed')
expect(switchElement).toHaveAttribute('aria-busy', 'true')
expect(switchElement).toHaveAttribute('data-disabled', '')
await expect.element(switchElement).toHaveClass('data-disabled:cursor-not-allowed')
await expect.element(switchElement).toHaveAttribute('aria-busy', 'true')
await expect.element(switchElement).toHaveAttribute('data-disabled', '')
await user.click(switchElement)
asHTMLElement(switchElement.element()).click()
expect(onCheckedChange).not.toHaveBeenCalled()
})
it('should show spinner icon for md and lg sizes', () => {
const { rerender, container } = render(<Switch checked={false} loading size="md" />)
expect(container.querySelector('span[aria-hidden="true"] i')).toBeInTheDocument()
it('should show spinner icon for md and lg sizes', async () => {
const screen = await render(<Switch checked={false} loading size="md" />)
expect(screen.container.querySelector('span[aria-hidden="true"] i')).toBeInTheDocument()
rerender(<Switch checked={false} loading size="lg" />)
expect(container.querySelector('span[aria-hidden="true"] i')).toBeInTheDocument()
await screen.rerender(<Switch checked={false} loading size="lg" />)
expect(screen.container.querySelector('span[aria-hidden="true"] i')).toBeInTheDocument()
})
it('should not show spinner for xs and sm sizes', () => {
const { rerender, container } = render(<Switch checked={false} loading size="xs" />)
expect(container.querySelector('span[aria-hidden="true"] i')).not.toBeInTheDocument()
it('should not show spinner for xs and sm sizes', async () => {
const screen = await render(<Switch checked={false} loading size="xs" />)
expect(screen.container.querySelector('span[aria-hidden="true"] i')).not.toBeInTheDocument()
rerender(<Switch checked={false} loading size="sm" />)
expect(container.querySelector('span[aria-hidden="true"] i')).not.toBeInTheDocument()
await screen.rerender(<Switch checked={false} loading size="sm" />)
expect(screen.container.querySelector('span[aria-hidden="true"] i')).not.toBeInTheDocument()
})
it('should apply disabled data-state hooks when loading', () => {
const { rerender } = render(<Switch checked={false} loading />)
const switchElement = screen.getByRole('switch')
it('should apply disabled data-state hooks when loading', async () => {
const screen = await render(<Switch checked={false} loading />)
expect(switchElement).toHaveAttribute('data-disabled', '')
await expect.element(screen.getByRole('switch')).toHaveAttribute('data-disabled', '')
rerender(<Switch checked={true} loading />)
expect(switchElement).toHaveAttribute('data-disabled', '')
expect(switchElement).toHaveAttribute('data-checked', '')
await screen.rerender(<Switch checked={true} loading />)
await expect.element(screen.getByRole('switch')).toHaveAttribute('data-disabled', '')
await expect.element(screen.getByRole('switch')).toHaveAttribute('data-checked', '')
})
})
})
describe('SwitchSkeleton', () => {
it('should render a plain div without switch role', () => {
render(<SwitchSkeleton data-testid="skeleton-switch" />)
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
expect(screen.getByTestId('skeleton-switch')).toBeInTheDocument()
it('should render a plain div without switch role', async () => {
const screen = await render(<SwitchSkeleton data-testid="skeleton-switch" />)
expect(screen.container.querySelector('[role="switch"]')).not.toBeInTheDocument()
await expect.element(screen.getByTestId('skeleton-switch')).toBeInTheDocument()
})
it('should apply skeleton styles', () => {
render(<SwitchSkeleton data-testid="skeleton-switch" />)
const el = screen.getByTestId('skeleton-switch')
expect(el).toHaveClass('bg-text-quaternary', 'opacity-20')
it('should apply skeleton styles', async () => {
const screen = await render(<SwitchSkeleton data-testid="skeleton-switch" />)
await expect.element(screen.getByTestId('skeleton-switch')).toHaveClass('bg-text-quaternary', 'opacity-20')
})
it('should apply correct skeleton size classes', () => {
const { rerender } = render(<SwitchSkeleton size="xs" data-testid="s" />)
const el = screen.getByTestId('s')
expect(el).toHaveClass('h-2.5', 'w-3.5', 'rounded-xs')
it('should apply correct skeleton size classes', async () => {
const screen = await render(<SwitchSkeleton size="xs" data-testid="s" />)
await expect.element(screen.getByTestId('s')).toHaveClass('h-2.5', 'w-3.5', 'rounded-xs')
rerender(<SwitchSkeleton size="lg" data-testid="s" />)
expect(el).toHaveClass('h-5', 'w-9', 'rounded-md')
await screen.rerender(<SwitchSkeleton size="lg" data-testid="s" />)
await expect.element(screen.getByTestId('s')).toHaveClass('h-5', 'w-9', 'rounded-md')
})
it('should apply custom className to skeleton', () => {
render(<SwitchSkeleton className="custom-class" data-testid="s" />)
expect(screen.getByTestId('s')).toHaveClass('custom-class')
it('should apply custom className to skeleton', async () => {
const screen = await render(<SwitchSkeleton className="custom-class" data-testid="s" />)
await expect.element(screen.getByTestId('s')).toHaveClass('custom-class')
})
})

View File

@@ -1,316 +1,236 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { render } from 'vitest-browser-react'
import { toast, ToastHost } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('base/ui/toast', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers({ shouldAdvanceTime: true })
act(() => {
toast.dismiss()
})
})
afterEach(() => {
act(() => {
toast.dismiss()
vi.runOnlyPendingTimers()
})
vi.useRealTimers()
})
// Core host and manager integration.
it('should render a success toast when called through the typed shortcut', async () => {
render(<ToastHost />)
const screen = await render(<ToastHost />)
act(() => {
toast.success('Saved', {
description: 'Your changes are available now.',
})
})
expect(await screen.findByText('Saved')).toBeInTheDocument()
expect(screen.getByText('Your changes are available now.')).toBeInTheDocument()
const viewport = screen.getByRole('region', { name: 'Notifications' })
expect(viewport).toHaveAttribute('aria-live', 'polite')
expect(viewport).toHaveClass('z-1003')
expect(viewport.firstElementChild).toHaveClass('top-4')
expect(screen.getByRole('dialog')).not.toHaveClass('outline-hidden')
await expect.element(screen.getByText('Saved')).toBeInTheDocument()
await expect.element(screen.getByText('Your changes are available now.')).toBeInTheDocument()
await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveAttribute('aria-live', 'polite')
await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveClass('z-1003')
expect(screen.getByRole('region', { name: 'Notifications' }).element().firstElementChild).toHaveClass('top-4')
expect(screen.getByRole('dialog').element()).not.toHaveClass('outline-hidden')
expect(document.body.querySelector('[aria-hidden="true"].i-ri-checkbox-circle-fill')).toBeInTheDocument()
expect(document.body.querySelector('button[aria-label="Close notification"][aria-hidden="true"]')).toBeInTheDocument()
})
// Collapsed stacks should keep multiple toast roots mounted for smooth stack animation.
it('should keep multiple toast roots mounted in a collapsed stack', async () => {
render(<ToastHost />)
const screen = await render(<ToastHost />)
act(() => {
toast('First toast')
})
await expect.element(screen.getByText('First toast')).toBeInTheDocument()
expect(await screen.findByText('First toast')).toBeInTheDocument()
act(() => {
toast('Second toast')
toast('Third toast')
})
expect(await screen.findByText('Third toast')).toBeInTheDocument()
expect(screen.getAllByRole('dialog')).toHaveLength(3)
await expect.element(screen.getByText('Third toast')).toBeInTheDocument()
expect(document.body.querySelectorAll('[role="dialog"]')).toHaveLength(3)
expect(document.body.querySelectorAll('button[aria-label="Close notification"][aria-hidden="true"]')).toHaveLength(3)
fireEvent.mouseEnter(screen.getByRole('region', { name: 'Notifications' }))
screen.getByRole('region', { name: 'Notifications' }).element().dispatchEvent(new MouseEvent('mouseover', {
bubbles: true,
}))
await waitFor(() => {
await vi.waitFor(() => {
expect(document.body.querySelector('button[aria-label="Close notification"][aria-hidden="true"]')).not.toBeInTheDocument()
})
})
// Neutral calls should map directly to a toast with only a title.
it('should render a neutral toast when called directly', async () => {
render(<ToastHost />)
const screen = await render(<ToastHost />)
act(() => {
toast('Neutral toast')
})
expect(await screen.findByText('Neutral toast')).toBeInTheDocument()
await expect.element(screen.getByText('Neutral toast')).toBeInTheDocument()
expect(document.body.querySelector('[aria-hidden="true"].i-ri-information-2-fill')).not.toBeInTheDocument()
})
// Base UI limit should cap the visible stack and mark overflow toasts as limited.
it('should mark overflow toasts as limited when the stack exceeds the configured limit', async () => {
render(<ToastHost limit={1} />)
const screen = await render(<ToastHost limit={1} />)
act(() => {
toast('First toast')
toast('Second toast')
})
expect(await screen.findByText('Second toast')).toBeInTheDocument()
await expect.element(screen.getByText('Second toast')).toBeInTheDocument()
expect(document.body.querySelector('[data-limited]')).toBeInTheDocument()
})
// Closing should work through the public manager API.
it('should dismiss a toast when dismiss(id) is called', async () => {
render(<ToastHost />)
const screen = await render(<ToastHost />)
let toastId = ''
act(() => {
toastId = toast('Closable', {
const toastId = toast('Closable', {
description: 'This toast can be removed.',
})
})
expect(await screen.findByText('Closable')).toBeInTheDocument()
await expect.element(screen.getByText('Closable')).toBeInTheDocument()
act(() => {
toast.dismiss(toastId)
})
await waitFor(() => {
expect(screen.queryByText('Closable')).not.toBeInTheDocument()
await vi.waitFor(() => {
expect(document.body).not.toHaveTextContent('Closable')
})
})
// User dismissal needs to remain accessible.
it('should close a toast when the dismiss button is clicked', async () => {
const onClose = vi.fn()
const screen = await render(<ToastHost />)
render(<ToastHost />)
act(() => {
toast('Dismiss me', {
description: 'Manual dismissal path.',
onClose,
})
})
fireEvent.mouseEnter(screen.getByRole('region', { name: 'Notifications' }))
screen.getByRole('region', { name: 'Notifications' }).element().dispatchEvent(new MouseEvent('mouseover', {
bubbles: true,
}))
const dismissButton = await screen.findByRole('button', { name: 'Close notification' })
await expect.element(screen.getByRole('button', { name: 'Close notification' })).toBeInTheDocument()
asHTMLElement(screen.getByRole('button', { name: 'Close notification' }).element()).click()
act(() => {
dismissButton.click()
})
await waitFor(() => {
expect(screen.queryByText('Dismiss me')).not.toBeInTheDocument()
await vi.waitFor(() => {
expect(document.body).not.toHaveTextContent('Dismiss me')
})
expect(onClose).toHaveBeenCalledTimes(1)
})
// Base UI default timeout should apply when no timeout is provided.
it('should auto dismiss toasts with the Base UI default timeout', async () => {
render(<ToastHost />)
const screen = await render(<ToastHost />)
act(() => {
toast('Default timeout')
})
await expect.element(screen.getByText('Default timeout')).toBeInTheDocument()
expect(await screen.findByText('Default timeout')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(4999)
expect(document.body).toHaveTextContent('Default timeout')
act(() => {
vi.advanceTimersByTime(4999)
})
expect(screen.getByText('Default timeout')).toBeInTheDocument()
act(() => {
vi.advanceTimersByTime(1)
})
await waitFor(() => {
expect(screen.queryByText('Default timeout')).not.toBeInTheDocument()
await vi.advanceTimersByTimeAsync(1)
await vi.waitFor(() => {
expect(document.body).not.toHaveTextContent('Default timeout')
})
})
// Provider timeout should apply to all toasts when configured.
it('should respect the host timeout configuration', async () => {
render(<ToastHost timeout={3000} />)
const screen = await render(<ToastHost timeout={3000} />)
act(() => {
toast('Configured timeout')
})
await expect.element(screen.getByText('Configured timeout')).toBeInTheDocument()
expect(await screen.findByText('Configured timeout')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(2999)
expect(document.body).toHaveTextContent('Configured timeout')
act(() => {
vi.advanceTimersByTime(2999)
})
expect(screen.getByText('Configured timeout')).toBeInTheDocument()
act(() => {
vi.advanceTimersByTime(1)
})
await waitFor(() => {
expect(screen.queryByText('Configured timeout')).not.toBeInTheDocument()
await vi.advanceTimersByTimeAsync(1)
await vi.waitFor(() => {
expect(document.body).not.toHaveTextContent('Configured timeout')
})
})
// Callers must be able to override or disable timeout per toast.
it('should respect custom timeout values including zero', async () => {
render(<ToastHost />)
const screen = await render(<ToastHost />)
act(() => {
toast('Custom timeout', {
timeout: 1000,
})
await expect.element(screen.getByText('Custom timeout')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(1000)
await vi.waitFor(() => {
expect(document.body).not.toHaveTextContent('Custom timeout')
})
expect(await screen.findByText('Custom timeout')).toBeInTheDocument()
act(() => {
vi.advanceTimersByTime(1000)
})
await waitFor(() => {
expect(screen.queryByText('Custom timeout')).not.toBeInTheDocument()
})
act(() => {
toast('Persistent', {
timeout: 0,
})
await expect.element(screen.getByText('Persistent')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(10000)
expect(document.body).toHaveTextContent('Persistent')
})
expect(await screen.findByText('Persistent')).toBeInTheDocument()
act(() => {
vi.advanceTimersByTime(10000)
})
expect(screen.getByText('Persistent')).toBeInTheDocument()
})
// Updates should flow through the same manager state.
it('should update an existing toast', async () => {
render(<ToastHost />)
const screen = await render(<ToastHost />)
let toastId = ''
act(() => {
toastId = toast.info('Loading', {
const toastId = toast.info('Loading', {
description: 'Preparing your data…',
})
})
await expect.element(screen.getByText('Loading')).toBeInTheDocument()
expect(await screen.findByText('Loading')).toBeInTheDocument()
act(() => {
toast.update(toastId, {
title: 'Done',
description: 'Your data is ready.',
type: 'success',
})
await expect.element(screen.getByText('Done')).toBeInTheDocument()
await expect.element(screen.getByText('Your data is ready.')).toBeInTheDocument()
expect(document.body).not.toHaveTextContent('Loading')
})
expect(screen.getByText('Done')).toBeInTheDocument()
expect(screen.getByText('Your data is ready.')).toBeInTheDocument()
expect(screen.queryByText('Loading')).not.toBeInTheDocument()
})
// Re-adding the same toast id should upsert in place instead of stacking duplicates.
it('should upsert an existing toast when add is called with the same id', async () => {
render(<ToastHost />)
const screen = await render(<ToastHost />)
act(() => {
toast('Syncing', {
id: 'sync-job',
description: 'Uploading changes…',
})
})
await expect.element(screen.getByText('Syncing')).toBeInTheDocument()
expect(await screen.findByText('Syncing')).toBeInTheDocument()
act(() => {
toast.success('Synced', {
id: 'sync-job',
description: 'All changes are uploaded.',
})
await vi.waitFor(() => {
expect(document.body).not.toHaveTextContent('Syncing')
})
await expect.element(screen.getByText('Synced')).toBeInTheDocument()
await expect.element(screen.getByText('All changes are uploaded.')).toBeInTheDocument()
expect(document.body.querySelectorAll('[role="dialog"]')).toHaveLength(1)
})
expect(screen.queryByText('Syncing')).not.toBeInTheDocument()
expect(screen.getByText('Synced')).toBeInTheDocument()
expect(screen.getByText('All changes are uploaded.')).toBeInTheDocument()
expect(screen.getAllByRole('dialog')).toHaveLength(1)
})
// Action props should pass through to the Base UI action button.
it('should render and invoke toast action props', async () => {
const onAction = vi.fn()
const screen = await render(<ToastHost />)
render(<ToastHost />)
act(() => {
toast('Action toast', {
actionProps: {
children: 'Undo',
onClick: onAction,
},
})
})
const actionButton = await screen.findByRole('button', { name: 'Undo' })
act(() => {
actionButton.click()
})
await expect.element(screen.getByRole('button', { name: 'Undo' })).toBeInTheDocument()
asHTMLElement(screen.getByRole('button', { name: 'Undo' }).element()).click()
expect(onAction).toHaveBeenCalledTimes(1)
})
// Promise helpers are part of the public API and need a regression test.
it('should transition a promise toast from loading to success', async () => {
render(<ToastHost />)
const screen = await render(<ToastHost />)
let resolvePromise: ((value: string) => void) | undefined
const promise = new Promise<string>((resolve) => {
resolvePromise = resolve
})
void act(() => toast.promise(promise, {
void toast.promise(promise, {
loading: 'Saving…',
success: result => ({
title: 'Saved',
@@ -318,16 +238,14 @@ describe('base/ui/toast', () => {
type: 'success',
}),
error: 'Failed',
}))
})
expect(await screen.findByText('Saving…')).toBeInTheDocument()
await expect.element(screen.getByText('Saving…')).toBeInTheDocument()
await act(async () => {
resolvePromise?.('Your changes are available now.')
await promise
})
expect(await screen.findByText('Saved')).toBeInTheDocument()
expect(screen.getByText('Your changes are available now.')).toBeInTheDocument()
await expect.element(screen.getByText('Saved')).toBeInTheDocument()
await expect.element(screen.getByText('Your changes are available now.')).toBeInTheDocument()
})
})

View File

@@ -1,11 +1,16 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-react'
import { Tooltip, TooltipContent, TooltipTrigger } from '../index'
const renderWithSafeViewport = (ui: import('react').ReactNode) => render(
<div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}>
{ui}
</div>,
)
describe('TooltipContent', () => {
describe('Placement and offsets', () => {
it('should use default top placement when placement is not provided', () => {
render(
it('should use default top placement when placement is not provided', async () => {
const screen = await renderWithSafeViewport(
<Tooltip open>
<TooltipTrigger aria-label="tooltip trigger">Trigger</TooltipTrigger>
<TooltipContent role="tooltip" aria-label="default tooltip">
@@ -14,14 +19,13 @@ describe('TooltipContent', () => {
</Tooltip>,
)
const popup = screen.getByRole('tooltip', { name: 'default tooltip' })
expect(popup).toHaveAttribute('data-side', 'top')
expect(popup).toHaveAttribute('data-align', 'center')
expect(popup).toHaveTextContent('Tooltip body')
await expect.element(screen.getByRole('tooltip', { name: 'default tooltip' })).toHaveAttribute('data-side', 'top')
await expect.element(screen.getByRole('tooltip', { name: 'default tooltip' })).toHaveAttribute('data-align', 'center')
await expect.element(screen.getByRole('tooltip', { name: 'default tooltip' })).toHaveTextContent('Tooltip body')
})
it('should apply custom placement when placement props are provided', () => {
render(
it('should apply custom placement when placement props are provided', async () => {
const screen = await renderWithSafeViewport(
<Tooltip open>
<TooltipTrigger aria-label="tooltip trigger">Trigger</TooltipTrigger>
<TooltipContent
@@ -36,16 +40,15 @@ describe('TooltipContent', () => {
</Tooltip>,
)
const popup = screen.getByRole('tooltip', { name: 'custom tooltip' })
expect(popup).toHaveAttribute('data-side', 'bottom')
expect(popup).toHaveAttribute('data-align', 'start')
expect(popup).toHaveTextContent('Custom tooltip body')
await expect.element(screen.getByRole('tooltip', { name: 'custom tooltip' })).toHaveAttribute('data-side', 'bottom')
await expect.element(screen.getByRole('tooltip', { name: 'custom tooltip' })).toHaveAttribute('data-align', 'start')
await expect.element(screen.getByRole('tooltip', { name: 'custom tooltip' })).toHaveTextContent('Custom tooltip body')
})
})
describe('Variant and popup props', () => {
it('should render popup content when variant is plain', () => {
render(
it('should render popup content when variant is plain', async () => {
const screen = await render(
<Tooltip open>
<TooltipTrigger aria-label="tooltip trigger">Trigger</TooltipTrigger>
<TooltipContent variant="plain" role="tooltip" aria-label="plain tooltip">
@@ -54,13 +57,13 @@ describe('TooltipContent', () => {
</Tooltip>,
)
expect(screen.getByRole('tooltip', { name: 'plain tooltip' })).toHaveTextContent('Plain tooltip body')
await expect.element(screen.getByRole('tooltip', { name: 'plain tooltip' })).toHaveTextContent('Plain tooltip body')
})
it('should forward popup props and handlers when popup props are provided', () => {
it('should forward popup props and handlers when popup props are provided', async () => {
const onMouseEnter = vi.fn()
render(
const screen = await render(
<Tooltip open>
<TooltipTrigger aria-label="tooltip trigger">Trigger</TooltipTrigger>
<TooltipContent
@@ -76,15 +79,15 @@ describe('TooltipContent', () => {
)
const popup = screen.getByRole('tooltip', { name: 'help text' })
fireEvent.mouseEnter(popup)
await popup.hover()
expect(popup).toHaveAttribute('id', 'tooltip-popup-id')
expect(popup).toHaveAttribute('data-track-id', 'tooltip-track')
await expect.element(popup).toHaveAttribute('id', 'tooltip-popup-id')
await expect.element(popup).toHaveAttribute('data-track-id', 'tooltip-track')
expect(onMouseEnter).toHaveBeenCalledTimes(1)
})
it('should apply className to the popup and positionerClassName to the positioner', () => {
render(
it('should apply className to the popup and positionerClassName to the positioner', async () => {
const screen = await render(
<Tooltip open>
<TooltipTrigger aria-label="tooltip trigger">Trigger</TooltipTrigger>
<TooltipContent
@@ -98,7 +101,7 @@ describe('TooltipContent', () => {
</Tooltip>,
)
const popup = screen.getByRole('tooltip', { name: 'styled tooltip' })
const popup = screen.getByRole('tooltip', { name: 'styled tooltip' }).element()
expect(popup).toHaveClass('popup-class')
expect(popup.parentElement).toHaveClass('positioner-class')
})

View File

@@ -1,44 +0,0 @@
import { act, cleanup } from '@testing-library/react'
import '@testing-library/jest-dom/vitest'
if (typeof Element !== 'undefined' && !Element.prototype.getAnimations)
Element.prototype.getAnimations = () => []
if (typeof document !== 'undefined' && !document.getAnimations)
document.getAnimations = () => []
if (typeof globalThis.ResizeObserver === 'undefined') {
globalThis.ResizeObserver = class {
observe() {
return undefined
}
unobserve() {
return undefined
}
disconnect() {
return undefined
}
}
}
if (typeof globalThis.IntersectionObserver === 'undefined') {
globalThis.IntersectionObserver = class {
readonly root: Element | Document | null = null
readonly rootMargin: string = ''
readonly scrollMargin: string = ''
readonly thresholds: ReadonlyArray<number> = []
constructor(_callback: IntersectionObserverCallback, _options?: IntersectionObserverInit) { /* noop */ }
observe(_target: Element) { /* noop */ }
unobserve(_target: Element) { /* noop */ }
disconnect() { /* noop */ }
takeRecords(): IntersectionObserverEntry[] { return [] }
}
}
afterEach(async () => {
await act(async () => {
cleanup()
})
})

View File

@@ -1,10 +1,6 @@
{
"extends": "@dify/tsconfig/react.json",
"compilerOptions": {
"rootDir": ".",
"types": ["vitest/globals", "@testing-library/jest-dom"],
"isolatedModules": true,
"verbatimModuleSyntax": true
},
"include": ["src", "tests", ".storybook", "tailwind.config.ts", "vite.config.ts"]
"types": ["vite-plus/test/globals"]
}
}

View File

@@ -1,5 +1,6 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite-plus'
import { playwright } from 'vite-plus/test/browser-playwright'
const isCI = !!process.env.CI
@@ -9,9 +10,13 @@ export default defineConfig({
tsconfigPaths: true,
},
test: {
environment: 'happy-dom',
globals: true,
setupFiles: ['./tests/setup.ts'],
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
headless: true,
},
coverage: {
provider: 'v8',
include: ['src/**/*.{ts,tsx}'],

1903
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,51 @@
packages:
- web
- e2e
- sdks/nodejs-client
- packages/*
allowBuilds:
'@parcel/watcher': false
canvas: false
esbuild: false
sharp: false
autoInstallPeers: false
blockExoticSubdeps: true
catalogMode: prefer
shellEmulator: true
strictDepBuilds: true
trustPolicy: no-downgrade
trustPolicyExclude:
- chokidar@4.0.3
- reselect@5.1.1
- semver@6.3.1
packages:
- web
- e2e
- sdks/nodejs-client
- packages/*
overrides:
'@lexical/code': npm:lexical-code-no-prism@0.41.0
'@monaco-editor/loader': 1.7.0
brace-expansion@>=2.0.0 <2.0.3: 2.0.3
canvas: ^3.2.2
dompurify@>=3.1.3 <=3.3.1: 3.3.2
esbuild@<0.27.2: 0.27.2
flatted@<=3.4.1: 3.4.2
glob@>=10.2.0 <10.5.0: 11.1.0
is-core-module: npm:@nolyfill/is-core-module@^1.0.39
lodash-es@>=4.0.0 <= 4.17.23: 4.18.0
lodash@>=4.0.0 <= 4.17.23: 4.18.0
picomatch@<2.3.2: 2.3.2
picomatch@>=4.0.0 <4.0.4: 4.0.4
rollup@>=4.0.0 <4.59.0: 4.59.0
safe-buffer: ^5.2.1
safer-buffer: npm:@nolyfill/safer-buffer@^1.0.44
side-channel: npm:@nolyfill/side-channel@^1.0.44
smol-toml@<1.6.1: 1.6.1
solid-js: 1.9.11
string-width: ~8.2.0
svgo@>=3.0.0 <3.3.3: 3.3.3
tar@<=7.5.10: 7.5.11
undici@>=7.0.0 <7.24.0: 7.24.0
vite: npm:@voidzero-dev/vite-plus-core@0.1.18
vitest: npm:@voidzero-dev/vite-plus-test@0.1.18
yaml@>=2.0.0 <2.8.3: 2.8.3
yauzl@<3.2.1: 3.2.1
catalog:
'@amplitude/analytics-browser': 2.39.0
'@amplitude/plugin-session-replay-browser': 1.27.7
@@ -16,7 +53,6 @@ catalog:
'@base-ui/react': 1.4.0
'@chromatic-com/storybook': 5.1.2
'@cucumber/cucumber': 12.8.0
'@date-fns/tz': 1.4.1
'@egoist/tailwindcss-icons': 1.9.2
'@emoji-mart/data': 1.2.1
'@eslint-react/eslint-plugin': 3.0.0
@@ -73,7 +109,6 @@ catalog:
'@testing-library/jest-dom': 6.9.1
'@testing-library/react': 16.3.2
'@testing-library/user-event': 14.6.1
'@tsdown/css': 0.21.8
'@tsslint/cli': 3.0.3
'@tsslint/compat-eslint': 3.0.3
'@tsslint/config': 3.0.3
@@ -81,7 +116,6 @@ catalog:
'@types/js-yaml': 4.0.9
'@types/negotiator': 0.6.4
'@types/node': 25.6.0
'@types/postcss-js': 4.1.0
'@types/qs': 6.15.0
'@types/react': 19.2.14
'@types/react-dom': 19.2.3
@@ -95,7 +129,6 @@ catalog:
abcjs: 6.6.2
agentation: 3.0.2
ahooks: 3.9.7
autoprefixer: 10.5.0
class-variance-authority: 0.7.1
client-only: 0.0.1
clsx: 2.1.1
@@ -103,7 +136,6 @@ catalog:
code-inspector-plugin: 1.5.1
copy-to-clipboard: 3.3.3
cron-parser: 5.5.0
date-fns: 4.1.0
dayjs: 1.11.20
decimal.js: 10.6.0
dompurify: 3.4.0
@@ -152,8 +184,8 @@ catalog:
next-themes: 0.4.6
nuqs: 2.8.9
pinyin-pro: 3.28.1
playwright: 1.59.1
postcss: 8.5.9
postcss-js: 5.1.0
qrcode.react: 4.2.0
qs: 6.15.1
react: 19.2.5
@@ -183,7 +215,6 @@ catalog:
tailwind-merge: 3.5.0
tailwindcss: 4.2.2
tldts: 7.0.28
tsdown: 0.21.8
tsx: 4.21.0
typescript: 6.0.2
uglify-js: 3.19.3
@@ -195,42 +226,8 @@ catalog:
vite-plugin-inspect: 12.0.0-beta.1
vite-plus: 0.1.18
vitest: npm:@voidzero-dev/vite-plus-test@0.1.18
vitest-browser-react: 2.2.0
vitest-canvas-mock: 1.1.4
zod: 4.3.6
zundo: 2.3.0
zustand: 5.0.12
catalogMode: prefer
overrides:
'@lexical/code': npm:lexical-code-no-prism@0.41.0
'@monaco-editor/loader': 1.7.0
brace-expansion@>=2.0.0 <2.0.3: 2.0.3
canvas: ^3.2.2
dompurify@>=3.1.3 <=3.3.1: 3.3.2
esbuild@<0.27.2: 0.27.2
flatted@<=3.4.1: 3.4.2
glob@>=10.2.0 <10.5.0: 11.1.0
is-core-module: npm:@nolyfill/is-core-module@^1.0.39
lodash-es@>=4.0.0 <= 4.17.23: 4.18.0
lodash@>=4.0.0 <= 4.17.23: 4.18.0
picomatch@<2.3.2: 2.3.2
picomatch@>=4.0.0 <4.0.4: 4.0.4
rollup@>=4.0.0 <4.59.0: 4.59.0
safe-buffer: ^5.2.1
safer-buffer: npm:@nolyfill/safer-buffer@^1.0.44
side-channel: npm:@nolyfill/side-channel@^1.0.44
smol-toml@<1.6.1: 1.6.1
solid-js: 1.9.11
string-width: ~8.2.0
svgo@>=3.0.0 <3.3.3: 3.3.3
tar@<=7.5.10: 7.5.11
undici@>=7.0.0 <7.24.0: 7.24.0
vite: npm:@voidzero-dev/vite-plus-core@0.1.18
vitest: npm:@voidzero-dev/vite-plus-test@0.1.18
yaml@>=2.0.0 <2.8.3: 2.8.3
yauzl@<3.2.1: 3.2.1
strictDepBuilds: true
trustPolicy: no-downgrade
trustPolicyExclude:
- chokidar@4.0.3
- reselect@5.1.1
- semver@6.3.1