mirror of
https://mirror.skon.top/github.com/langgenius/dify.git
synced 2026-04-20 15:20:15 +08:00
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
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:
3
.github/workflows/web-tests.yml
vendored
3
.github/workflows/web-tests.yml
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
1903
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user