diff --git a/.vscode/settings.json b/.vscode/settings.json index e0ffa241..47a2ef9a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,12 +2,8 @@ "i18n-ally.localesPaths": [ "**/locales" ], - - "eslint.enable": true, - "biome.enabled": false, - //"eslint.useFlatConfig": true, - //"eslint.useESLintClass": false, // важно! + "editor.tabSize": 4, // Disable the default formatter, use eslint instead "prettier.enable": false, @@ -15,26 +11,22 @@ // Auto fix "editor.codeActionsOnSave": { - //"source.fixAll.biome": "explicit", - //"source.organizeImports.biome": "explicit", - "source.fixAll.eslint": "explicit", "source.organizeImports": "never" }, - //"eslint.runtime": "node", - // Silent the stylistic rules in you IDE, but still auto fix them "eslint.rules.customizations": [ - { "rule": "style/*", "severity": "info", "fixable": true }, - { "rule": "*-indent", "severity": "info", "fixable": true }, - { "rule": "*-spacing", "severity": "info", "fixable": true }, - { "rule": "*-spaces", "severity": "info", "fixable": true }, - { "rule": "*-order", "severity": "info", "fixable": true }, - { "rule": "*-dangle", "severity": "info", "fixable": true }, - { "rule": "*-newline", "severity": "info", "fixable": true }, - { "rule": "*quotes", "severity": "info", "fixable": true }, - { "rule": "*semi", "severity": "info", "fixable": true } + { "rule": "style/*", "severity": "off", "fixable": true }, + { "rule": "format/*", "severity": "off", "fixable": true }, + { "rule": "*-indent", "severity": "off", "fixable": true }, + { "rule": "*-spacing", "severity": "off", "fixable": true }, + { "rule": "*-spaces", "severity": "off", "fixable": true }, + { "rule": "*-order", "severity": "off", "fixable": true }, + { "rule": "*-dangle", "severity": "off", "fixable": true }, + { "rule": "*-newline", "severity": "off", "fixable": true }, + { "rule": "*quotes", "severity": "off", "fixable": true }, + { "rule": "*semi", "severity": "off", "fixable": true } ], // Enable eslint for all supported languages @@ -47,17 +39,18 @@ "html", "markdown", "json", - "json5", "jsonc", "yaml", "toml", - "xml" - ], - - "pair-diff.patterns": [ - { - "source": "./fixtures/output/**/*.*", - "target": "./fixtures/input/" - } + "xml", + "gql", + "graphql", + "astro", + "svelte", + "css", + "less", + "scss", + "pcss", + "postcss" ] } \ No newline at end of file diff --git a/package.json b/package.json index 9df6a9ad..45e9e83a 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,9 @@ "@changesets/cli": "^2.28.1", "turbo": "^2.5.0" }, - "packageManager": "pnpm@10.7.0", + "packageManager": "pnpm@10.14.0", "engines": { "node": ">=20", - "pnpm": ">=10.7.0" + "pnpm": ">=10.14.0" } } diff --git a/packages/eslint/src/index.ts b/packages/eslint/src/index.ts index 9a31d583..bf042445 100644 --- a/packages/eslint/src/index.ts +++ b/packages/eslint/src/index.ts @@ -5,83 +5,100 @@ import turboPlugin from 'eslint-plugin-turbo'; import globals from 'globals'; export const overridesStylisticConfig: Exclude['overrides'] = { - /* comma */ - 'style/comma-dangle': ['error', 'never'], + /* comma */ + 'style/comma-dangle': ['error', 'never'], - /* spacing rules */ - 'style/type-annotation-spacing': ['error', { before: false, after: true, overrides: { arrow: { before: false, after: true } } }], - 'style/type-generic-spacing': ['error'], - 'style/type-named-tuple-spacing': ['error'], - 'style/template-tag-spacing': ['error'], + /* spacing rules */ + 'style/type-annotation-spacing': ['error', { + before: false, + after: true, + overrides: { arrow: { before: true, after: true } } + }], + 'style/type-generic-spacing': ['error'], + 'style/type-named-tuple-spacing': ['error'], + 'style/template-tag-spacing': ['error'], - /* misc */ - 'style/max-len': ['warn', { code: 140, tabWidth: 2, ignoreTrailingComments: true, ignoreUrls: true, ignoreStrings: true, ignoreTemplateLiterals: true, ignoreRegExpLiterals: true, ignorePattern: '^\\s*var\\s.+=\\s*require\\s*\\(' }], - 'style/one-var-declaration-per-line': ['error', 'always'], - 'style/max-statements-per-line': ['error', { max: 3 }], + /* misc */ + 'style/max-len': ['warn', { + code: 140, + tabWidth: 4, + ignoreTrailingComments: true, + ignoreUrls: true, + ignoreStrings: true, + ignoreTemplateLiterals: true, + ignoreRegExpLiterals: true + }], + 'style/indent': ['warn', 4], + 'style/one-var-declaration-per-line': ['error', 'always'], + 'style/max-statements-per-line': ['error', { max: 3 }], + 'style/newline-per-chained-call': ['error', { ignoreChainWithDepth: 3 }], + 'style/object-curly-newline': ['warn', { consistent: true, minProperties: 4 }], + 'style/array-bracket-newline': ['warn', { minItems: 4 }], - /* jsx */ - 'style/jsx-quotes': ['error', 'prefer-single'], - 'style/jsx-curly-brace-presence': ['warn', 'always'], - 'style/jsx-curly-spacing': [2, { when: 'always' }], + /* jsx */ + 'style/jsx-quotes': ['error', 'prefer-single'], + 'style/jsx-curly-brace-presence': ['warn', 'always'], + 'style/jsx-curly-spacing': [2, { when: 'never' }], - /* semis */ - 'style/no-extra-semi': 'error', + /* semis */ + 'style/no-extra-semi': 'error', - /* bracket */ - 'style/arrow-parens': ['warn', 'always'] + /* bracket */ + 'style/arrow-parens': ['warn', 'always'] }; export const overridesTsConfig: Exclude['overrides'] = { - 'ts/consistent-type-exports': 'error', - 'ts/consistent-type-imports': 'error', - 'ts/consistent-type-definitions': [ - 'error', - 'type' - ], - 'ts/naming-convention': [ - 'warn', - { - format: ['camelCase', 'UPPER_CASE', 'PascalCase'], - selector: 'variable' - }, - { - format: ['PascalCase'], - selector: 'typeLike' - } - ] + 'ts/consistent-type-exports': 'error', + 'ts/consistent-type-imports': 'error', + 'ts/consistent-type-definitions': ['error', 'type'], + 'ts/naming-convention': [ + 'warn', + { + format: [ + 'camelCase', + 'UPPER_CASE', + 'PascalCase' + ], + selector: 'variable' + }, + { + format: ['PascalCase'], + selector: 'typeLike' + } + ] }; export const general: TypedFlatConfigItem[] = [ - { - plugins: { - turbo: turboPlugin + { + plugins: { + turbo: turboPlugin + }, + rules: { + 'turbo/no-undeclared-env-vars': 'warn', + 'ts/consistent-type-definitions': ['error', 'type'], + 'no-console': ['warn'], + 'antfu/no-top-level-await': ['off'], + 'node/prefer-global/process': ['off'], + 'node/no-process-env': ['error'], + 'perfectionist/sort-imports': ['error', { + tsconfigRootDir: import.meta.dirname + }] + } }, - rules: { - 'turbo/no-undeclared-env-vars': 'warn', - 'ts/consistent-type-definitions': ['error', 'type'], - 'no-console': ['warn'], - 'antfu/no-top-level-await': ['off'], - 'node/prefer-global/process': ['off'], - 'node/no-process-env': ['error'], - 'perfectionist/sort-imports': ['error', { - tsconfigRootDir: import.meta.dirname - }] - } - }, - { - languageOptions: { - globals: { - ...globals.browser, - ...globals.chai, - ...globals.mocha, - ...globals.node, - ...globals.es2024 - } + { + languageOptions: { + globals: { + ...globals.browser, + ...globals.chai, + ...globals.mocha, + ...globals.node, + ...globals.es2024 + } + } + }, + { + ignores: ['dist/**'] } - }, - { - ignores: ['dist/**'] - } ]; export type ESLintAntfuConfig = ReturnType; @@ -99,27 +116,27 @@ export const createEslintConfig: typeof antfu = antfu; * @param {string} dirname - The directory name to use for the config. */ export function eslintReactConfig(dirname: string): ESLintAntfuConfig { - return antfu( - { - pnpm: true, - react: true, - typescript: { - parserOptions: { - projectService: true, - tsconfigRootDir: dirname - }, - overrides: overridesTsConfig - }, - stylistic: { - jsx: true, - semi: true, - overrides: overridesStylisticConfig - }, - jsx: true, - formatters: true, - ...general - } - ); + return antfu( + { + pnpm: true, + react: true, + typescript: { + parserOptions: { + projectService: true, + tsconfigRootDir: dirname + }, + overrides: overridesTsConfig + }, + stylistic: { + jsx: true, + semi: true, + overrides: overridesStylisticConfig + }, + jsx: true, + formatters: true, + ...general + } + ); } /** @@ -129,25 +146,25 @@ export function eslintReactConfig(dirname: string): ESLintAntfuConfig { * @param {string} dirname - The directory name to use for the config. */ export function eslintNodeConfig(dirname: string): ESLintAntfuConfig { - return antfu( - { - pnpm: true, - react: false, - typescript: { - parserOptions: { - projectService: true, - tsconfigRootDir: dirname - }, - overrides: overridesTsConfig - }, - stylistic: { - jsx: false, - semi: true, - overrides: overridesStylisticConfig - }, - jsx: false, - formatters: true, - ...general - } - ); + return antfu( + { + pnpm: true, + react: false, + typescript: { + parserOptions: { + projectService: true, + tsconfigRootDir: dirname + }, + overrides: overridesTsConfig + }, + stylistic: { + jsx: false, + semi: true, + overrides: overridesStylisticConfig + }, + jsx: false, + formatters: true, + ...general + } + ); } diff --git a/packages/eslint/tsconfig.json b/packages/eslint/tsconfig.json index 19c2cde9..10026686 100644 --- a/packages/eslint/tsconfig.json +++ b/packages/eslint/tsconfig.json @@ -1,12 +1,12 @@ { "extends": "@flippo/tsconfig", "compilerOptions": { + "composite": false, "types": ["node"], "allowImportingTsExtensions": false, "allowJs": true, "declaration": false, "declarationMap": false, - "composite": false, "emitDeclarationOnly": false, "isolatedDeclarations": false }, diff --git a/packages/test/.gitignore b/packages/test/.gitignore new file mode 100644 index 00000000..e0f1a94b --- /dev/null +++ b/packages/test/.gitignore @@ -0,0 +1,34 @@ +# Dependencies +node_modules/ +.pnpm-store/ + +# Build outputs +dist/ +build/ +*.tsbuildinfo + +# Coverage reports +coverage/ +.nyc_output/ + +# Environment files +.env +.env.local +.env.test + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db + +# Test artifacts +test-results/ +*.log + +# Vitest cache +.vitest/ diff --git a/packages/test/CHANGELOG.md b/packages/test/CHANGELOG.md new file mode 100644 index 00000000..f41ae2f4 --- /dev/null +++ b/packages/test/CHANGELOG.md @@ -0,0 +1,45 @@ +# Changelog + +All notable changes to `@flippo/internal-test-utils` will be documented in this file. + +## [1.0.0] - 2024-12-XX + +### Added +- Initial release of @flippo/internal-test-utils +- `createRenderer` - Standardized renderer for headless components +- `HeadlessTestUtils` - Specialized utilities for headless UI testing +- `AccessibilityTestRunner` - Comprehensive a11y testing +- `PerformanceTestRunner` - Component performance monitoring +- `EventTestRunner` - Advanced event testing capabilities +- Custom Vitest matchers for headless components: + - `toHaveHeadlessUIAttributes` + - `toHaveHeadlessFocus` + - `toHaveCompoundParts` + - `toHaveEventHandler` + - `toPreventHeadlessUIHandler` + - `toBeProperlyPositioned` +- Support for compound component testing patterns +- RTL/LTR testing support +- Performance monitoring and thresholds +- Debug mode with enhanced logging +- Quick setup configurations for different testing scenarios +- Comprehensive TypeScript types +- Integration with Vitest test runner +- Examples and documentation + +### Features +- **Compound Component Support**: Testing utilities designed for components that follow the compound pattern (Root, Trigger, Popup, etc.) +- **HeadlessUI Event System**: Support for testing the custom event prevention system +- **Floating UI Integration**: Special handling for components using @floating-ui +- **Performance Monitoring**: Built-in performance tracking and threshold validation +- **Accessibility Testing**: Automated a11y checks with ARIA validation +- **Cross-Browser Compatibility**: Mocking and polyfills for consistent testing +- **Debug Tools**: Enhanced debugging capabilities with DOM inspection +- **TypeScript First**: Full TypeScript support with comprehensive types + +### Documentation +- Complete API documentation +- Real-world usage examples +- Migration guide from @testing-library/react +- Best practices for headless component testing +- Troubleshooting guide diff --git a/packages/test/INTEGRATION_GUIDE.md b/packages/test/INTEGRATION_GUIDE.md new file mode 100644 index 00000000..18c61969 --- /dev/null +++ b/packages/test/INTEGRATION_GUIDE.md @@ -0,0 +1,407 @@ +# Руководство по интеграции @flippo/internal-test-utils + +## Интеграция с существующими компонентами + +### 1. Обновление конфигурации headless-components + +Добавьте в `packages/ui/uikit/headless/components/package.json`: + +```json +{ + "devDependencies": { + "@flippo/internal-test-utils": "workspace:*" + } +} +``` + +### 2. Обновление Vitest конфигурации + +Обновите `packages/ui/uikit/headless/components/vite.config.ts`: + +```typescript +import path from 'node:path'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['@flippo/internal-test-utils/setup'], // Используем готовую настройку + css: true, + include: ['src/**/*.{test,spec}.{ts,tsx}'], + coverage: { + reporter: ['text', 'json', 'html'], + exclude: ['node_modules/', 'src/test/', '**/*.test.*'], + }, + }, + // ... остальная конфигурация +}); +``` + +### 3. Обновление setup файла + +Замените содержимое `packages/ui/uikit/headless/components/src/test/setup.ts`: + +```typescript +import { init } from '@flippo/internal-test-utils'; + +// Инициализация с настройками для headless компонентов +init({ + enableA11yTesting: true, + enablePerformanceMonitoring: process.env.NODE_ENV === 'development', + debug: process.env.DEBUG_TESTS === 'true', + defaultDirection: 'ltr', + performanceThresholds: { + maxRenderTime: 100, + maxUpdateTime: 50, + maxMemoryUsage: 10, + }, +}); +``` + +### 4. Миграция существующих тестов + +#### Tooltip тесты + +Существующий тест: +```typescript +// src/components/Tooltip/root/TooltipRoot.test.tsx +import React from 'react'; +import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Tooltip } from '..'; + +describe('tooltipRoot', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should open and close on hover', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + // ... rest of test + }); +}); +``` + +Обновленный тест с @flippo/internal-test-utils: +```typescript +// src/components/Tooltip/root/TooltipRoot.test.tsx +import React from 'react'; +import { describe, it, vi } from 'vitest'; +import { + createRenderer, + createHeadlessTestUtils, + testAriaRelationships, + createA11yTestRunner, + expect, +} from '@flippo/internal-test-utils'; + +import { Tooltip } from '..'; + +describe('TooltipRoot', () => { + const { render } = createRenderer(); + + it('should open and close on hover', async () => { + const result = render( + + + Trigger + + + Popup + + + + + ); + + const utils = createHeadlessTestUtils(result); + const trigger = result.getByText('Trigger'); + + // Используем специализированную утилиту для hover тестов + await utils.testHoverInteraction({ + trigger, + expectedContent: 'Popup', + shouldAppear: true, + }); + }); + + it('should pass accessibility tests', async () => { + const result = render( + + + Accessible trigger + + + Accessible content + + + + + ); + + // Автоматическое тестирование доступности + const trigger = result.getByText('Accessible trigger'); + const a11yRunner = createA11yTestRunner(result); + + const results = await a11yRunner.runAllTests(trigger); + expect(results.every(r => r.passed)).toBe(true); + + // Проверка ARIA связей + await testAriaRelationships({ + renderResult: result, + relationships: [ + { + source: 'tooltip-trigger', + target: 'tooltip-popup', + attribute: 'aria-describedby', + }, + ], + }); + }); + + it('should handle state changes correctly', async () => { + const onOpenChange = vi.fn(); + + const result = render( + + + State trigger + + + State content + + + + + ); + + const utils = createHeadlessTestUtils(result); + const trigger = result.getByText('State trigger'); + + // Тестирование состояния через утилиты + await utils.testStateChange({ + trigger, + action: async () => { + await result.user.hover(trigger); + }, + expectedStateChange: async () => { + expect(onOpenChange).toHaveBeenCalledWith(true); + }, + }); + }); +}); +``` + +### 5. Создание тестов для новых компонентов + +Пример структуры теста для нового компонента: + +```typescript +// src/components/NewComponent/NewComponent.test.tsx +import React from 'react'; +import { describe, it, vi } from 'vitest'; +import { + createRenderer, + createHeadlessTestUtils, + testComponentPerformance, + createA11yTestRunner, + expect, +} from '@flippo/internal-test-utils'; + +import { NewComponent } from './NewComponent'; + +describe('NewComponent', () => { + const { render } = createRenderer(); + + describe('Basic functionality', () => { + it('should render correctly', () => { + const result = render(Test content); + expect(result.getByText('Test content')).toBeInTheDocument(); + }); + + it('should handle props correctly', () => { + const onClick = vi.fn(); + const result = render( + + Clickable content + + ); + + const element = result.getByText('Clickable content'); + result.user.click(element); + + expect(onClick).toHaveBeenCalled(); + }); + }); + + describe('Accessibility', () => { + it('should pass all accessibility tests', async () => { + const result = render(Accessible content); + + const element = result.getByText('Accessible content'); + const a11yRunner = createA11yTestRunner(result); + + const results = await a11yRunner.runAllTests(element); + expect(results.every(r => r.passed)).toBe(true); + }); + + it('should support keyboard navigation', async () => { + const result = render(Focusable); + const utils = createHeadlessTestUtils(result); + const element = result.getByText('Focusable'); + + await utils.testKeyboardNavigation({ + trigger: element, + key: 'Enter', + }); + }); + }); + + describe('Performance', () => { + it('should render within performance thresholds', async () => { + await testComponentPerformance({ + Component: NewComponent, + props: { children: 'Performance test' }, + thresholds: { + maxRenderTime: 50, + maxUpdateTime: 25, + }, + }); + }); + }); + + describe('Custom behavior', () => { + it('should use headless UI specific matchers', () => { + const result = render( + + ); + + const element = result.getByTestId('custom-component'); + + // Используем кастомные матчеры + expect(element).toHaveHeadlessUIAttributes({ + 'role': 'button', + 'aria-expanded': false, + }); + + expect(element).toHaveEventHandler('click'); + }); + }); +}); +``` + +## Рекомендуемая структура тестов + +``` +src/ +├── components/ +│ ├── ComponentName/ +│ │ ├── ComponentName.tsx +│ │ ├── ComponentName.test.tsx # Основные тесты +│ │ ├── ComponentName.a11y.test.tsx # Тесты доступности +│ │ ├── ComponentName.perf.test.tsx # Тесты производительности +│ │ └── parts/ +│ │ ├── Part1/ +│ │ │ ├── Part1.tsx +│ │ │ └── Part1.test.tsx +│ │ └── Part2/ +│ │ ├── Part2.tsx +│ │ └── Part2.test.tsx +│ └── ... +``` + +## Паттерны тестирования для разных типов компонентов + +### Compound Components (Dialog, Menu, Tooltip) + +```typescript +import { testCompoundPatterns, testStateSynchronization } from '@flippo/internal-test-utils'; + +const componentTester = testCompoundPatterns().dialog; // или menu, tooltip +await componentTester.testPartConnections(ui); +await testStateSynchronization({ renderResult, stateChanges }); +``` + +### Form Components (Input, Checkbox, Select) + +```typescript +import { createHeadlessTestUtils, createEventTestRunner } from '@flippo/internal-test-utils'; + +const utils = createHeadlessTestUtils(result); +const eventRunner = createEventTestRunner(result); + +await utils.testEventHandler({ + element: input, + event: 'change', + handler: onChange, + expectedArgs: [{ target: { value: 'test' } }], +}); +``` + +### Interactive Components (Button, Toggle, Switch) + +```typescript +import { testKeyboardPatterns, KEYBOARD_PATTERNS } from '@flippo/internal-test-utils'; + +await testKeyboardPatterns(button, KEYBOARD_PATTERNS.BUTTON); +``` + +### Floating Components (Popover, ContextMenu) + +```typescript +expect(popup).toBeProperlyPositioned({ + top: 100, + left: 50, +}); +``` + +## Запуск тестов + +```bash +# Запуск всех тестов +pnpm test + +# Запуск тестов в watch режиме +pnpm test:watch + +# Запуск тестов с UI +pnpm test:ui + +# Запуск только accessibility тестов +pnpm test -- --grep="accessibility|a11y" + +# Запуск только performance тестов +pnpm test -- --grep="performance|perf" + +# Запуск в debug режиме +DEBUG_TESTS=true pnpm test + +# Пропуск медленных тестов +SKIP_SLOW_TESTS=true pnpm test +``` + +## Миграция по шагам + +1. **Установите пакет** в headless-components +2. **Обновите vitest.config.ts** для использования готового setup +3. **Мигрируйте по одному компоненту** начиная с простых +4. **Добавьте accessibility тесты** для каждого компонента +5. **Настройте performance тесты** для критически важных компонентов +6. **Добавьте интеграционные тесты** между компонентами + +Это поможет постепенно перейти на новую систему тестирования без нарушения существующих тестов. diff --git a/packages/test/MUI_INTERNAL_TEST_UTILS_DOCUMENTATION.md b/packages/test/MUI_INTERNAL_TEST_UTILS_DOCUMENTATION.md new file mode 100644 index 00000000..1c9434d1 --- /dev/null +++ b/packages/test/MUI_INTERNAL_TEST_UTILS_DOCUMENTATION.md @@ -0,0 +1,629 @@ +# @mui/internal-test-utils Documentation + +## Overview + +The `@mui/internal-test-utils` package is an internal utility library designed specifically for testing within the MUI (Material-UI) ecosystem. This package provides a comprehensive suite of functions and tools that standardize test runner initialization and offer common testing utilities shared across all MUI packages. + +> **⚠️ Important Note**: This package is intended for internal MUI development use only and is not meant for general public consumption. It's designed to maintain consistency and efficiency across MUI's internal testing infrastructure. + +## Table of Contents + +1. [Installation](#installation) +2. [Core Functions](#core-functions) +3. [Test Environment Setup](#test-environment-setup) +4. [Testing Framework Configuration](#testing-framework-configuration) +5. [Custom Matchers and Assertions](#custom-matchers-and-assertions) +6. [Best Practices](#best-practices) +7. [Usage Examples](#usage-examples) + +## Installation + +```bash +npm install @mui/internal-test-utils --save-dev +# or +yarn add @mui/internal-test-utils --dev +# or +pnpm add @mui/internal-test-utils --save-dev +``` + +## Core Functions + +### `createRenderer` + +**Purpose**: Initializes the test suite and returns a function with the same interface as `render` from `@testing-library/react`, ensuring consistent rendering behavior across all tests. + +**Type Signature**: +```typescript +function createRenderer(options?: RendererOptions): { + render: (ui: ReactElement, options?: RenderOptions) => RenderResult; + cleanup: () => void; +} +``` + +**Why it exists**: +- Provides a standardized rendering function across all MUI tests +- Ensures consistent test environment setup +- Abstracts away common test setup boilerplate +- Handles cleanup automatically between tests + +**Usage Example**: +```javascript +import { createRenderer } from '@mui/internal-test-utils'; + +describe('Button Component', () => { + const { render } = createRenderer(); + + it('should render with correct text', () => { + const { getByText } = render(); + expect(getByText('Click me')).not.to.equal(null); + }); + + it('should handle click events', () => { + const handleClick = spy(); + const { getByRole } = render(); + + fireEvent.click(getByRole('button')); + expect(handleClick.calledOnce).to.equal(true); + }); +}); +``` + +**Key Features**: +- Automatic cleanup between tests +- Consistent theme provider setup +- Standardized error boundary handling +- Performance monitoring integration + +### `createDescribe` + +**Purpose**: Provides an enhanced wrapper around the standard `describe` function to facilitate creation of test suites with additional configurations, setups, or shared behaviors. + +**Type Signature**: +```typescript +function createDescribe( + name: string, + options?: DescribeOptions +): (callback: () => void) => void +``` + +**Why it exists**: +- Standardizes test suite configuration across MUI packages +- Provides common setup and teardown logic +- Enables conditional test execution based on environment +- Reduces boilerplate code in test files + +**Usage Example**: +```javascript +import { createDescribe } from '@mui/internal-test-utils'; + +const describe = createDescribe('Component Integration Tests', { + theme: 'dark', + viewport: 'mobile' +}); + +describe(() => { + it('should work in dark theme', () => { + // Test implementation + }); +}); +``` + +## Test Environment Setup + +### `init` + +**Purpose**: Initializes the entire testing environment by setting up necessary configurations, global variables, and environmental conditions required for MUI testing. + +**Type Signature**: +```typescript +function init(config?: InitConfig): void +``` + +**Why it exists**: +- Ensures consistent test environment across different test runners +- Sets up global polyfills and configurations +- Initializes performance monitoring +- Configures error handling for tests + +**Usage Example**: +```javascript +import { init } from '@mui/internal-test-utils'; + +// In test setup file (e.g., setupTests.js) +init({ + enablePerformanceMonitoring: true, + errorBoundary: true, + theme: 'light' +}); +``` + +### `setupJSDOM` + +**Purpose**: Configures and initializes a JSDOM environment to simulate a browser-like environment within Node.js, enabling DOM interactions in tests without requiring a real browser. + +**Type Signature**: +```typescript +function setupJSDOM(options?: JSDOMOptions): void +``` + +**Why it exists**: +- Enables DOM testing in Node.js environment +- Provides consistent DOM API across test runs +- Supports browser-specific APIs like `window`, `document`, etc. +- Faster than running tests in real browsers + +**Features**: +- Configures global DOM objects (`window`, `document`, `navigator`) +- Sets up event handling +- Provides mock implementations for browser APIs +- Handles cleanup between test runs + +**Usage Example**: +```javascript +import { setupJSDOM } from '@mui/internal-test-utils'; + +// In test setup file +setupJSDOM({ + url: 'http://localhost:3000', + pretendToBeVisual: true, + resources: 'usable' +}); +``` + +## Testing Framework Configuration + +### `setupVitest` + +**Purpose**: Configures the Vitest testing framework with MUI-specific settings, plugins, and optimizations for efficient test execution. + +**Type Signature**: +```typescript +function setupVitest(config?: VitestConfig): void +``` + +**Why it exists**: +- Optimizes Vitest for MUI component testing +- Configures proper TypeScript handling +- Sets up mock providers and utilities +- Enables fast refresh and hot module replacement in tests + +**Configuration Features**: +- Custom resolver for MUI packages +- TypeScript configuration +- Mock setup for external dependencies +- Performance optimizations + +### `setupKarma` + +**Purpose**: Configures the Karma test runner for executing tests across multiple browsers, ensuring cross-browser compatibility of MUI components. + +**Type Signature**: +```typescript +function setupKarma(config?: KarmaConfig): void +``` + +**Why it exists**: +- Enables cross-browser testing +- Provides consistent test execution environment +- Integrates with CI/CD pipelines +- Supports headless browser testing + +**Features**: +- Browser configuration (Chrome, Firefox, Safari) +- Webpack integration for module bundling +- Coverage reporting setup +- Performance monitoring + +### `setupBabel` / `setupBabelPlaywright` + +**Purpose**: Configures Babel transpilation for test environments, with specialized setup for different testing frameworks. + +**Type Signatures**: +```typescript +function setupBabel(config?: BabelConfig): void +function setupBabelPlaywright(config?: BabelPlaywrightConfig): void +``` + +**Why they exist**: +- Ensure modern JavaScript features work in test environments +- Handle TypeScript transpilation +- Configure JSX transformation +- Support experimental features used in MUI + +**Key Babel Configurations**: +- React JSX transformation +- TypeScript preset +- Dynamic imports support +- Emotion CSS-in-JS transformation + +## Custom Matchers and Assertions + +### `initMatchers` + +**Purpose**: Registers custom Jest/Vitest matchers that extend assertion capabilities with MUI-specific testing utilities. + +**Type Signature**: +```typescript +function initMatchers(): void +``` + +**Available Custom Matchers**: +- `toHaveVirtualFocus()` - Tests virtual focus state +- `toBeAccessible()` - Validates accessibility compliance +- `toHaveTheme()` - Checks theme application +- `toMatchComponentSnapshot()` - Component-specific snapshot testing + +**Usage Example**: +```javascript +import { initMatchers } from '@mui/internal-test-utils'; + +// In test setup +initMatchers(); + +// In tests +expect(element).toHaveVirtualFocus(); +expect(component).toBeAccessible(); +expect(styledElement).toHaveTheme('primary'); +``` + +### `initPlaywrightMatchers` + +**Purpose**: Provides Playwright-specific custom matchers for enhanced end-to-end testing assertions. + +**Type Signature**: +```typescript +function initPlaywrightMatchers(): void +``` + +**Playwright-Specific Matchers**: +- `toBeVisibleInViewport()` - Checks element visibility +- `toHaveCorrectColors()` - Validates color scheme application +- `toPassAccessibilityAudit()` - Runs automated accessibility checks +- `toHaveCorrectFocus()` - Validates focus management + +### `chaiPlugin` + +**Purpose**: Extends the Chai assertion library with MUI-specific plugins and assertions. + +**Type Signature**: +```typescript +function chaiPlugin(chai: ChaiStatic, utils: ChaiUtils): void +``` + +**Custom Chai Assertions**: +- Component state validation +- Theme consistency checks +- Accessibility compliance verification +- Performance threshold validation + +## Testing Framework Utilities + +### `describeSkipIf` + +**Purpose**: Provides conditional test execution, allowing test suites to be dynamically skipped based on runtime conditions or environment variables. + +**Type Signature**: +```typescript +function describeSkipIf( + condition: boolean | (() => boolean), + suiteName: string, + suiteCallback: () => void +): void +``` + +**Why it exists**: +- Handles environment-specific testing (browser support, feature flags) +- Enables progressive testing rollouts +- Supports conditional CI/CD testing +- Prevents flaky tests in unsupported environments + +**Usage Example**: +```javascript +import { describeSkipIf } from '@mui/internal-test-utils'; + +// Skip tests in environments that don't support certain features +describeSkipIf( + !('ResizeObserver' in window), + 'Responsive Component Tests', + () => { + it('should resize correctly', () => { + // Test implementation + }); + } +); + +// Skip based on environment variable +describeSkipIf( + process.env.SKIP_SLOW_TESTS === 'true', + 'Performance Tests', + () => { + it('should render within performance threshold', () => { + // Slow test implementation + }); + } +); +``` + +### `KarmaReporterReactProfiler` + +**Purpose**: Custom Karma reporter that integrates with React Profiler to collect and report performance metrics during test execution. + +**Type Signature**: +```typescript +class KarmaReporterReactProfiler { + constructor(baseReporterDecorator: Function, config: KarmaConfig); + onRunStart(): void; + onBrowserStart(browser: Browser): void; + onSpecComplete(browser: Browser, result: TestResult): void; + onRunComplete(): void; +} +``` + +**Why it exists**: +- Monitors component performance during testing +- Identifies performance regressions early +- Provides detailed timing information +- Integrates performance testing into CI/CD + +**Performance Metrics Collected**: +- Component render time +- Update performance +- Memory usage patterns +- Bundle size impact + +## Advanced Configuration + +### Environment-Specific Setup + +The package provides different setup functions for various testing environments: + +```javascript +// For Jest/Vitest environment +import { setupJSDOM, initMatchers } from '@mui/internal-test-utils'; + +setupJSDOM(); +initMatchers(); + +// For Playwright environment +import { setupBabelPlaywright, initPlaywrightMatchers } from '@mui/internal-test-utils'; + +setupBabelPlaywright(); +initPlaywrightMatchers(); + +// For Karma environment +import { setupKarma, setupBabel } from '@mui/internal-test-utils'; + +setupKarma({ + browsers: ['Chrome', 'Firefox'], + singleRun: true +}); +setupBabel(); +``` + +## Best Practices + +### 1. Test Suite Organization + +```javascript +import { createRenderer, createDescribe } from '@mui/internal-test-utils'; + +const describe = createDescribe('Component Test Suite'); + +describe(() => { + const { render } = createRenderer(); + + beforeEach(() => { + // Common setup + }); + + it('should pass accessibility tests', () => { + const { container } = render(); + expect(container.firstChild).toBeAccessible(); + }); +}); +``` + +### 2. Performance Testing Integration + +```javascript +import { createRenderer, KarmaReporterReactProfiler } from '@mui/internal-test-utils'; + +describe('Performance Tests', () => { + const { render } = createRenderer({ + enableProfiling: true + }); + + it('should render within performance threshold', () => { + const start = performance.now(); + render(); + const end = performance.now(); + + expect(end - start).toBeLessThan(100); // 100ms threshold + }); +}); +``` + +### 3. Cross-Framework Testing + +```javascript +// Conditional setup based on test runner +import { + setupVitest, + setupKarma, + describeSkipIf +} from '@mui/internal-test-utils'; + +if (typeof vitest !== 'undefined') { + setupVitest(); +} else { + setupKarma(); +} + +// Skip tests based on capabilities +describeSkipIf( + typeof IntersectionObserver === 'undefined', + 'Intersection Observer Tests', + () => { + // Tests that require IntersectionObserver + } +); +``` + +## Common Usage Patterns + +### Complete Test Setup + +```javascript +import { + createRenderer, + init, + initMatchers, + setupJSDOM +} from '@mui/internal-test-utils'; + +// Initialize testing environment +init({ + enablePerformanceMonitoring: true, + errorBoundary: true +}); + +setupJSDOM({ + url: 'http://localhost:3000', + pretendToBeVisual: true +}); + +initMatchers(); + +// Create renderer for test suite +const { render } = createRenderer(); + +describe('Component Integration Tests', () => { + it('should integrate properly with theme', () => { + const { container } = render( + + + + ); + + expect(container.firstChild).toHaveTheme('primary'); + }); +}); +``` + +### Playwright E2E Testing Setup + +```javascript +import { + setupBabelPlaywright, + initPlaywrightMatchers, + describeSkipIf +} from '@mui/internal-test-utils'; + +// Setup for Playwright +setupBabelPlaywright({ + targets: { node: 'current' }, + modules: false +}); + +initPlaywrightMatchers(); + +describeSkipIf( + process.env.CI && process.env.BROWSER === 'webkit', + 'Safari-specific tests', + () => { + test('should handle touch interactions', async ({ page }) => { + await page.goto('/button-demo'); + + const button = page.locator('[data-testid="touch-button"]'); + await expect(button).toBeVisibleInViewport(); + await expect(button).toPassAccessibilityAudit(); + }); + } +); +``` + +## Architecture and Design Principles + +### Consistency +The package ensures that all MUI packages follow the same testing patterns and configurations, reducing cognitive load for developers and maintaining quality standards. + +### Performance +Built-in performance monitoring and optimization features help identify performance regressions early in the development cycle. + +### Accessibility +Integrated accessibility testing ensures that MUI components meet accessibility standards out of the box. + +### Cross-Platform Compatibility +Provides utilities to handle testing across different browsers, devices, and environments consistently. + +## Function Reference Summary + +| Function | Purpose | Primary Use Case | +|----------|---------|------------------| +| `createRenderer` | Standardized React component rendering | Unit/Integration tests | +| `createDescribe` | Enhanced test suite creation | Test organization | +| `init` | Global test environment initialization | Test setup | +| `setupJSDOM` | DOM environment simulation | Node.js testing | +| `setupVitest` | Vitest framework configuration | Modern test runner setup | +| `setupKarma` | Karma test runner configuration | Cross-browser testing | +| `setupBabel` | Babel transpilation setup | Code transformation | +| `setupBabelPlaywright` | Playwright-specific Babel config | E2E testing | +| `initMatchers` | Custom assertion registration | Enhanced testing assertions | +| `initPlaywrightMatchers` | Playwright assertion extensions | E2E testing assertions | +| `chaiPlugin` | Chai assertion extensions | Chai-based testing | +| `describeSkipIf` | Conditional test execution | Environment-specific testing | +| `KarmaReporterReactProfiler` | Performance monitoring | Performance testing | + +## Integration with MUI Ecosystem + +The `@mui/internal-test-utils` package is deeply integrated with the MUI ecosystem: + +- **Theme Testing**: Provides utilities to test components across different MUI themes +- **Component API Testing**: Offers standardized ways to test component props and behaviors +- **Accessibility Testing**: Built-in accessibility validation for all MUI components +- **Performance Monitoring**: Tracks component performance across the entire library +- **Cross-Package Consistency**: Ensures testing patterns are consistent across all MUI packages + +## Migration and Compatibility + +The package is designed to be backward-compatible while providing migration paths for newer testing approaches: + +- Gradual migration from Jest to Vitest +- Transition from Karma to modern test runners +- Support for both legacy and modern React testing patterns +- Compatibility with different TypeScript configurations + +## Troubleshooting + +### Common Issues + +1. **JSDOM Environment Issues**: Use `setupJSDOM()` before running DOM-dependent tests +2. **Custom Matcher Not Found**: Ensure `initMatchers()` is called in test setup +3. **Babel Transpilation Errors**: Check that appropriate `setupBabel*` function is used +4. **Performance Test Failures**: Verify `KarmaReporterReactProfiler` is properly configured + +### Debug Mode + +```javascript +import { init } from '@mui/internal-test-utils'; + +init({ + debug: true, // Enables verbose logging + enablePerformanceMonitoring: true, + logLevel: 'debug' +}); +``` + +## Contributing Guidelines + +When working with `@mui/internal-test-utils`: + +1. **Follow Established Patterns**: Use existing utilities rather than creating new ones +2. **Maintain Backward Compatibility**: Ensure changes don't break existing tests +3. **Document New Features**: Add comprehensive documentation for new utilities +4. **Performance Considerations**: Monitor the impact of changes on test execution time +5. **Cross-Platform Testing**: Verify utilities work across all supported environments + +--- + +This package represents a crucial component of MUI's testing infrastructure, enabling consistent, reliable, and efficient testing across the entire ecosystem. Its utilities abstract away complex setup requirements while providing powerful testing capabilities tailored specifically for React component libraries. diff --git a/packages/test/OVERVIEW.md b/packages/test/OVERVIEW.md new file mode 100644 index 00000000..5169eac0 --- /dev/null +++ b/packages/test/OVERVIEW.md @@ -0,0 +1,244 @@ +# @flippo/internal-test-utils - Обзор библиотеки + +## 🎯 Цель + +Библиотека `@flippo/internal-test-utils` создана специально для тестирования headless UI компонентов экосистемы Flippo. Она решает специфичные задачи, которые возникают при тестировании unstyled компонентов с compound архитектурой. + +## 🧩 Архитектура + +### Основные принципы + +1. **Headless-First**: Утилиты адаптированы под особенности headless компонентов +2. **Compound Pattern**: Поддержка тестирования составных компонентов (Root + Parts) +3. **Event System**: Интеграция с HeadlessUIEvent системой +4. **Accessibility**: Встроенное a11y тестирование по умолчанию +5. **Performance**: Мониторинг производительности из коробки + +### Структура пакета + +``` +@flippo/internal-test-utils/ +├── renderer/ # Рендеринг компонентов +│ ├── createRenderer # Основная функция рендеринга +│ └── providers/ # Тестовые провайдеры +├── utils/ # Специализированные утилиты +│ ├── headless # Утилиты для headless компонентов +│ ├── accessibility # A11y тестирование +│ ├── performance # Performance мониторинг +│ ├── events # Тестирование событий +│ └── compound # Тестирование compound компонентов +├── matchers/ # Кастомные Jest/Vitest матчеры +├── setup/ # Настройка тестовой среды +├── types/ # TypeScript типы +└── examples/ # Примеры использования +``` + +## 🚀 Ключевые особенности + +### 1. Стандартизированный рендеринг + +```typescript +const { render } = createRenderer({ + direction: 'ltr', // RTL/LTR поддержка + enablePerformanceMonitoring: true, // Мониторинг производительности + autoCleanup: true, // Автоочистка между тестами +}); +``` + +### 2. Специализированные утилиты + +```typescript +const utils = createHeadlessTestUtils(result); + +// Тестирование hover взаимодействий +await utils.testHoverInteraction({ + trigger: button, + expectedContent: 'Tooltip text', +}); + +// Тестирование keyboard навигации +await utils.testKeyboardNavigation({ + trigger: menuItem, + key: 'ArrowDown', + expectedTarget: nextMenuItem, +}); +``` + +### 3. Compound Component тестирование + +```typescript +const dialogTester = testCompoundPatterns().dialog; + +await dialogTester.testPartConnections( + + Open + Content + +); +``` + +### 4. Accessibility из коробки + +```typescript +const a11yRunner = createA11yTestRunner(result); +const results = await a11yRunner.runAllTests(element); + +// Автоматически тестирует: +// - Keyboard navigation +// - Screen reader support +// - Focus management +// - ARIA relationships +``` + +### 5. Performance мониторинг + +```typescript +await testComponentPerformance({ + Component: ExpensiveComponent, + thresholds: { + maxRenderTime: 100, + maxUpdateTime: 50, + }, +}); +``` + +### 6. Кастомные матчеры + +```typescript +expect(element).toHaveHeadlessUIAttributes({ + 'aria-expanded': false, + 'role': 'button', +}); + +expect(popup).toBeProperlyPositioned({ top: 100, left: 50 }); +expect(element).toHaveHeadlessFocus(); +``` + +## 🎨 Отличия от @mui/internal-test-utils + +| Особенность | @mui/internal-test-utils | @flippo/internal-test-utils | +|-------------|--------------------------|------------------------------| +| **Целевые компоненты** | Styled компоненты MUI | Headless компоненты Flippo | +| **Архитектура** | Monolithic компоненты | Compound компоненты | +| **Event System** | Стандартные React события | HeadlessUIEvent система | +| **Styling** | Theme-based тестирование | Position-based тестирование | +| **Accessibility** | Базовая a11y поддержка | Продвинутая a11y валидация | +| **Performance** | Общий мониторинг | Headless-специфичные метрики | + +## 📋 Сравнение с стандартным подходом + +### Без @flippo/internal-test-utils + +```typescript +describe('Tooltip', () => { + it('should show on hover', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + + render( + + + Trigger + + + Content + + + + + ); + + const trigger = screen.getByText('Trigger'); + + await user.hover(trigger); + await waitFor(() => expect(screen.getByText('Content')).toBeInTheDocument()); + + await user.unhover(trigger); + await waitForElementToBeRemoved(() => screen.queryByText('Content')); + }); +}); +``` + +### С @flippo/internal-test-utils + +```typescript +describe('Tooltip', () => { + const { render } = createRenderer(); // Автонастройка провайдеров + + it('should show on hover', async () => { + const result = render( + + + Trigger + + + Content + + + + + ); + + const utils = createHeadlessTestUtils(result); + const trigger = result.getByText('Trigger'); + + // Все логика hover тестирования инкапсулирована + await utils.testHoverInteraction({ + trigger, + expectedContent: 'Content', + }); + }); + + // Bonus: автоматические a11y тесты + it('should pass accessibility tests', async () => { + const result = render(/* same UI */); + const a11yRunner = createA11yTestRunner(result); + + await a11yRunner.runAllTests(result.container); + }); +}); +``` + +## 💡 Выгоды использования + +### Для разработчиков +- **Меньше boilerplate кода** в тестах +- **Консистентность** тестов во всех компонентах +- **Автоматическая a11y проверка** без дополнительных усилий +- **Встроенный debug режим** для отладки тестов + +### Для команды +- **Стандартизация** подходов к тестированию +- **Повышение качества** за счет автоматических проверок +- **Ускорение разработки** новых компонентов +- **Улучшение CI/CD** за счет performance мониторинга + +### Для пользователей библиотеки +- **Более надежные компоненты** благодаря комплексному тестированию +- **Лучшая доступность** за счет автоматических a11y проверок +- **Стабильная производительность** благодаря performance тестам + +## 🔄 Интеграция с существующим кодом + +1. **Постепенная миграция** - можно мигрировать по одному компоненту +2. **Обратная совместимость** - работает с существующими @testing-library тестами +3. **Легкое внедрение** - минимальные изменения в существующих тестах + +## 📊 Метрики и мониторинг + +Библиотека автоматически собирает: +- **Время рендеринга** компонентов +- **Время обновления** при изменении пропсов +- **Использование памяти** +- **Результаты a11y тестов** +- **Coverage headless-специфичных паттернов** + +## 🎓 Обучение и принятие + +- **Документация на русском языке** для вашей команды +- **Реальные примеры** с вашими компонентами +- **Пошаговое руководство** по интеграции +- **Best practices** для headless UI тестирования + +--- + +Эта библиотека создана специально под архитектуру ваших headless компонентов и решает проблемы, специфичные для данного подхода к разработке UI. diff --git a/packages/test/README.md b/packages/test/README.md new file mode 100644 index 00000000..b0b0f216 --- /dev/null +++ b/packages/test/README.md @@ -0,0 +1,600 @@ +# @flippo/internal-test-utils + +> Внутренняя библиотека тестовых утилит для Flippo headless UI компонентов + +## Обзор + +`@flippo/internal-test-utils` - это специализированная библиотека для тестирования headless компонентов в экосистеме Flippo. Она предоставляет набор утилит, которые стандартизируют процесс тестирования и обеспечивают единообразие тестов во всех пакетах. + +### Ключевые особенности + +- 🧩 **Поддержка compound компонентов** - специальные утилиты для тестирования составных компонентов +- ♿ **Встроенное тестирование доступности** - автоматические проверки a11y +- ⚡ **Мониторинг производительности** - отслеживание времени рендера и обновлений +- 🎯 **Кастомные матчеры** - специфичные для headless UI проверки +- 🌍 **RTL поддержка** - тестирование right-to-left макетов +- 🔄 **Управление событиями** - продвинутое тестирование HeadlessUIEvent + +## Установка + +```bash +pnpm add @flippo/internal-test-utils --save-dev +``` + +## Быстрый старт + +```typescript +import { init, createRenderer, quickSetup } from '@flippo/internal-test-utils'; + +// Быстрая настройка для unit тестов +quickSetup.unit(); + +// Или полная настройка +init({ + enableA11yTesting: true, + enablePerformanceMonitoring: false, + debug: false, +}); + +// Создание renderer'а +const { render } = createRenderer(); + +describe('MyComponent', () => { + it('should render correctly', () => { + const { getByText } = render(Hello); + expect(getByText('Hello')).toBeInTheDocument(); + }); +}); +``` + +## Основные функции + +### `createRenderer` + +Создает стандартизированную функцию рендеринга для headless компонентов. + +```typescript +import { createRenderer } from '@flippo/internal-test-utils'; + +describe('Component tests', () => { + const { render } = createRenderer({ + direction: 'ltr', // или 'rtl' + enablePerformanceMonitoring: true, + autoCleanup: true, + }); + + it('should render with providers', () => { + const result = render(); + // Автоматически получаем: + // - DirectionProvider + // - TestIdProvider + // - PerformanceMonitor (если включен) + // - Enhanced утилиты (user, getByTestId, etc.) + }); +}); +``` + +### Тестирование compound компонентов + +```typescript +import { testCompoundComponent, testAriaRelationships } from '@flippo/internal-test-utils'; + +describe('Dialog Component', () => { + const dialogTester = testCompoundComponent({ + rootComponent: 'Dialog', + requiredChildren: ['DialogTrigger', 'DialogPopup'], + provider: DialogProvider, + }); + + it('should connect all parts properly', async () => { + const utils = await dialogTester.testPartConnections( + + Open + + Content + + + ); + + // Тест ARIA связей между частями + await testAriaRelationships({ + renderResult: utils.renderResult, + relationships: [ + { + source: 'dialog-trigger', + target: 'dialog-popup', + attribute: 'aria-controls', + }, + ], + }); + }); +}); +``` + +### Тестирование доступности + +```typescript +import { createA11yTestRunner, testKeyboardPatterns, KEYBOARD_PATTERNS } from '@flippo/internal-test-utils'; + +describe('Accessibility tests', () => { + it('should pass all a11y checks', async () => { + const { render } = createRenderer(); + const result = render(); + + const element = result.getByRole('button'); + const a11yRunner = createA11yTestRunner(result, { + keyboardNavigation: true, + screenReader: true, + focusManagement: true, + }); + + const testResults = await a11yRunner.runAllTests(element); + expect(testResults.every(r => r.passed)).toBe(true); + }); + + it('should support keyboard patterns', async () => { + const { render } = createRenderer(); + const result = render(); + + const menu = result.getByRole('menu'); + await testKeyboardPatterns(menu, KEYBOARD_PATTERNS.MENU); + }); +}); +``` + +### Тестирование производительности + +```typescript +import { testComponentPerformance, createPerformanceRunner, testStressPerformance } from '@flippo/internal-test-utils'; + +describe('Performance tests', () => { + it('should render within performance thresholds', async () => { + const result = await testComponentPerformance({ + Component: MyExpensiveComponent, + props: { data: largeDataSet }, + thresholds: { + maxRenderTime: 100, + maxUpdateTime: 50, + maxMemoryUsage: 10, + }, + }); + + expect(result.passed).toBe(true); + }); + + it('should handle multiple updates efficiently', async () => { + const stressResult = await testStressPerformance({ + Component: MyComponent, + initialProps: { value: 0 }, + propChanges: Array.from({ length: 100 }, (_, i) => ({ value: i })), + }); + + expect(stressResult.passed).toBe(true); + expect(stressResult.averageUpdateTime).toBeLessThan(10); + }); +}); +``` + +### Тестирование событий + +```typescript +import { createEventTestRunner, testCommonEventPatterns, createMockHeadlessUIEvent } from '@flippo/internal-test-utils'; + +describe('Event handling', () => { + it('should handle HeadlessUI events properly', async () => { + const { render } = createRenderer(); + const onClick = vi.fn(); + + const result = render(); + const eventRunner = createEventTestRunner(result); + + await eventRunner.testEventPrevention({ + element: result.getByRole('button'), + eventType: 'click', + handler: onClick, + preventHandler: false, // Test normal flow + }); + }); + + it('should support event prevention', async () => { + const { render } = createRenderer(); + const result = render(); + + const patterns = testCommonEventPatterns(); + + // Test click outside behavior + await patterns.clickOutside({ + renderResult: result, + triggerElement: result.getByRole('button'), + onClickOutside: vi.fn(), + }); + + // Test escape key behavior + await patterns.escapeKey({ + element: result.getByRole('button'), + onEscape: vi.fn(), + }); + }); +}); +``` + +### Кастомные матчеры + +```typescript +import { initMatchers } from '@flippo/internal-test-utils'; + +// Инициализация матчеров (обычно в setup файле) +initMatchers(); + +describe('Custom matchers', () => { + it('should use headless UI specific matchers', () => { + const { render } = createRenderer(); + const result = render(); + + const element = result.getByRole('button'); + + // Кастомные матчеры для headless компонентов + expect(element).toHaveHeadlessUIAttributes({ + 'aria-expanded': false, + 'aria-controls': null, + }); + + expect(element).toHaveHeadlessFocus(); + expect(element).toHaveEventHandler('click'); + expect(result.container).toHaveCompoundParts(['Trigger', 'Popup']); + }); +}); +``` + +### Условное пропускание тестов + +```typescript +import { describeSkipIf } from '@flippo/internal-test-utils'; + +// Пропуск тестов в зависимости от условий +describeSkipIf( + !('IntersectionObserver' in window), + 'IntersectionObserver tests', + () => { + it('should observe intersections', () => { + // Тесты, которые требуют IntersectionObserver + }); + } +); + +// Пропуск на основе переменных окружения +describeSkipIf( + process.env.SKIP_SLOW_TESTS === 'true', + 'Slow performance tests', + () => { + it('should pass stress test', () => { + // Медленные тесты + }); + } +); +``` + +## Конфигурация Vitest + +Создайте `vitest.config.ts` в вашем проекте: + +```typescript +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['@flippo/internal-test-utils/setup'], // Автоматическая настройка + include: ['src/**/*.{test,spec}.{ts,tsx}'], + }, +}); +``` + +Или настройте вручную в setup файле: + +```typescript +// setup.ts +import { init } from '@flippo/internal-test-utils'; + +init({ + enableA11yTesting: true, + enablePerformanceMonitoring: process.env.NODE_ENV === 'development', + debug: process.env.DEBUG_TESTS === 'true', + defaultDirection: 'ltr', + performanceThresholds: { + maxRenderTime: 100, + maxUpdateTime: 50, + maxMemoryUsage: 10, + }, +}); +``` + +## Примеры реального использования + +### Тестирование Tooltip компонента + +```typescript +import { createRenderer, createHeadlessTestUtils, testAriaRelationships } from '@flippo/internal-test-utils'; + +describe('Tooltip Component', () => { + const { render } = createRenderer(); + + it('should show/hide on hover', async () => { + const result = render( + + + Hover me + + + Tooltip content + + + + + ); + + const utils = createHeadlessTestUtils(result); + const trigger = result.getByText('Hover me'); + + await utils.testHoverInteraction({ + trigger, + expectedContent: 'Tooltip content', + }); + }); + + it('should maintain proper ARIA relationships', async () => { + // ... render logic ... + + await testAriaRelationships({ + renderResult: result, + relationships: [ + { + source: 'tooltip-trigger', + target: 'tooltip-popup', + attribute: 'aria-describedby', + }, + ], + }); + }); +}); +``` + +### Тестирование Dialog компонента + +```typescript +import { testCompoundPatterns, testStateSynchronization } from '@flippo/internal-test-utils'; + +describe('Dialog Component', () => { + const dialogTester = testCompoundPatterns().dialog; + + it('should handle state synchronization', async () => { + const onOpenChange = vi.fn(); + const result = render( + + Open + + Content + + + ); + + await testStateSynchronization({ + renderResult: result, + stateChanges: [ + { + action: async () => { + await user.click(result.getByText('Open')); + }, + expectedStates: [ + { + element: 'dialog-popup', + attribute: 'aria-hidden', + value: false, + }, + ], + }, + ], + }); + }); +}); +``` + +## API Reference + +### Core Functions + +- **`createRenderer(options?)`** - Создает renderer с провайдерами +- **`init(config?)`** - Инициализирует тестовую среду +- **`quickSetup`** - Быстрые настройки для разных сценариев + +### Testing Utilities + +- **`createHeadlessTestUtils(result)`** - Утилиты для headless компонентов +- **`createA11yTestRunner(result, options?)`** - Тестирование доступности +- **`createPerformanceRunner(thresholds?)`** - Тестирование производительности +- **`createEventTestRunner(result)`** - Тестирование событий + +### Compound Component Testing + +- **`testCompoundComponent(config)`** - Тестирование составных компонентов +- **`testCompoundPatterns()`** - Готовые паттерны для compound компонентов +- **`testAriaRelationships(config)`** - Тестирование ARIA связей +- **`testStateSynchronization(config)`** - Синхронизация состояния + +### Custom Matchers + +- **`toHaveHeadlessUIAttributes(attrs, options?)`** - Проверка headless атрибутов +- **`toHaveHeadlessFocus()`** - Проверка фокуса (включая виртуальный) +- **`toHaveCompoundParts(parts)`** - Наличие частей compound компонента +- **`toHaveEventHandler(eventType)`** - Наличие обработчика события +- **`toPreventHeadlessUIHandler()`** - Предотвращение default обработчика +- **`toBeProperlyPositioned(position?)`** - Корректное позиционирование + +### Event Testing + +- **`testCommonEventPatterns()`** - Общие паттерны событий +- **`createMockHeadlessUIEvent(event, options?)`** - Мок HeadlessUIEvent +- **`testEventHandlerMerging(config)`** - Тестирование слияния обработчиков + +## Лучшие практики + +### 1. Структура тестов + +```typescript +import { createRenderer, createHeadlessTestUtils } from '@flippo/internal-test-utils'; + +describe('ComponentName', () => { + const { render } = createRenderer(); + + it('should handle basic interaction', async () => { + const result = render(); + const utils = createHeadlessTestUtils(result); + + // Используйте utils для специфичного тестирования + await utils.testHoverInteraction({ + trigger: result.getByRole('button'), + expectedContent: 'Expected content', + }); + }); +}); +``` + +### 2. Тестирование производительности + +```typescript +import { testComponentPerformance } from '@flippo/internal-test-utils'; + +it('should meet performance requirements', async () => { + await testComponentPerformance({ + Component: MyComponent, + props: { complexProp: largeData }, + thresholds: { + maxRenderTime: 100, + maxUpdateTime: 50, + }, + }); +}); +``` + +### 3. Организация accessibility тестов + +```typescript +import { createA11yTestRunner, KEYBOARD_PATTERNS } from '@flippo/internal-test-utils'; + +describe('Accessibility', () => { + it('should pass all a11y tests', async () => { + const result = render(); + const runner = createA11yTestRunner(result); + + await runner.runAllTests(result.container); + }); +}); +``` + +## Дебаггинг + +Включите debug режим для получения дополнительной информации: + +```typescript +import { setupDebugMode } from '@flippo/internal-test-utils'; + +setupDebugMode(); + +// Теперь доступны глобальные функции: +debugElement(element); // Информация об элементе +debugDOM(); // Текущее состояние DOM +``` + +## Миграция с других тестовых библиотек + +### С @testing-library/react + +```typescript +// Старый код +import { render } from '@testing-library/react'; + +// Новый код +import { createRenderer } from '@flippo/internal-test-utils'; +const { render } = createRenderer(); +``` + +### С кастомными setup файлами + +```typescript +// Вместо множества setup файлов +import { quickSetup } from '@flippo/internal-test-utils'; + +quickSetup.unit(); // или integration, performance, accessibility +``` + +## Интеграция с CI/CD + +```typescript +// В CI окружении +import { init } from '@flippo/internal-test-utils'; + +init({ + enablePerformanceMonitoring: process.env.CI === 'true', + debug: process.env.DEBUG_TESTS === 'true', + performanceThresholds: { + maxRenderTime: process.env.CI ? 200 : 100, // Более мягкие лимиты в CI + }, +}); +``` + +## Troubleshooting + +### Распространенные проблемы + +1. **"Custom matchers not found"** + ```typescript + // Убедитесь что вызвали инициализацию + import { init } from '@flippo/internal-test-utils'; + init(); + ``` + +2. **"JSDOM errors"** + ```typescript + // Убедитесь что JSDOM правильно настроен в vitest.config.ts + export default defineConfig({ + test: { + environment: 'jsdom', + }, + }); + ``` + +3. **"Performance tests fail in CI"** + ```typescript + // Настройте разные пороги для CI + const thresholds = process.env.CI + ? { maxRenderTime: 200, maxUpdateTime: 100 } + : { maxRenderTime: 100, maxUpdateTime: 50 }; + ``` + +## Внутренняя архитектура + +Библиотека построена с акцентом на: + +- **Модульность** - каждая утилита независима +- **Расширяемость** - легко добавлять новые матчеры и утилиты +- **Производительность** - минимальный overhead для тестов +- **Типобезопасность** - полная поддержка TypeScript +- **Совместимость** - работа с различными тестовыми фреймворками + +Библиотека специально адаптирована для headless UI компонентов и учитывает их особенности: +- Compound component pattern +- HeadlessUIEvent система +- Управление фокусом и доступностью +- Floating UI интеграция +- Portal рендеринг + +## Примеры + +Смотрите полные примеры использования в папке `/src/examples/` этого пакета. + +--- + +**Важно**: Эта библиотека предназначена для внутреннего использования в экосистеме Flippo и оптимизирована под специфику headless UI компонентов. diff --git a/packages/test/package.json b/packages/test/package.json new file mode 100644 index 00000000..e750d6c9 --- /dev/null +++ b/packages/test/package.json @@ -0,0 +1,63 @@ +{ + "name": "@flippo/internal-test-utils", + "version": "1.0.0", + "description": "Internal testing utilities for Flippo headless UI components", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest", + "test:ui": "vitest --ui" + }, + "keywords": [ + "testing", + "test-utils", + "headless-ui", + "react", + "flippo", + "internal" + ], + "author": "@BlackPoretsky", + "license": "ISC", + "private": true, + "packageManager": "pnpm@10.14.0", + "dependencies": { + "@testing-library/jest-dom": "^6.1.0", + "@testing-library/react": "^14.0.0", + "@testing-library/user-event": "^14.5.0", + "@vitest/ui": "^1.0.0", + "vitest": "^1.0.0", + "jsdom": "^22.0.0" + }, + "devDependencies": { + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "@types/node": "^20.0.0", + "typescript": "^5.0.0", + "tsup": "^8.0.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0", + "vitest": ">=1.0.0" + } +} diff --git a/packages/test/setup.ts b/packages/test/setup.ts new file mode 100644 index 00000000..4d011dd0 --- /dev/null +++ b/packages/test/setup.ts @@ -0,0 +1,5 @@ +/** + * Quick setup entry point for Vitest setupFiles + */ + +export * from './src/setup/global'; diff --git a/packages/test/src/examples/index.ts b/packages/test/src/examples/index.ts new file mode 100644 index 00000000..2453cd07 --- /dev/null +++ b/packages/test/src/examples/index.ts @@ -0,0 +1,268 @@ +/** + * Example usage patterns for @flippo/internal-test-utils + * + * These examples demonstrate how to use the testing utilities + * with different types of headless components. + */ + +import * as React from 'react'; +import { vi } from 'vitest'; + +import { + createRenderer, + createHeadlessTestUtils, + testCompoundComponent, + createA11yTestRunner, + testComponentPerformance, + KEYBOARD_PATTERNS, + testCommonEventPatterns, +} from '../index'; + +/** + * Example: Testing a simple Button component + */ +export const buttonExample = { + // Mock Button component for example + Component: ({ children, onClick, disabled, ...props }: any) => + React.createElement('button', { onClick, disabled, ...props }, children), + + async testBasicInteraction() { + const { render } = createRenderer(); + const onClick = vi.fn(); + + const result = render( + + Click me + + ); + + const utils = createHeadlessTestUtils(result); + const button = result.getByRole('button'); + + // Test click interaction + await utils.testEventHandler({ + element: button, + event: 'user.click', + handler: onClick, + }); + + // Test keyboard accessibility + await utils.testKeyboardNavigation({ + trigger: button, + key: 'Enter', + }); + + return result; + }, + + async testAccessibility() { + const { render } = createRenderer(); + const result = render(Accessible Button); + + const button = result.getByRole('button'); + const a11yRunner = createA11yTestRunner(result); + + await a11yRunner.runAllTests(button); + }, + + async testPerformance() { + await testComponentPerformance({ + Component: this.Component, + props: { children: 'Performance Test' }, + thresholds: { + maxRenderTime: 10, // Very fast for simple button + maxUpdateTime: 5, + maxMemoryUsage: 1, + }, + }); + }, +}; + +/** + * Example: Testing a compound Tooltip component + */ +export const tooltipExample = { + // Mock compound Tooltip components + TooltipProvider: ({ children }: any) => React.createElement('div', { 'data-tooltip-provider': true }, children), + TooltipRoot: ({ children, onOpenChange }: any) => React.createElement('div', { 'data-tooltip-root': true }, children), + TooltipTrigger: ({ children, ...props }: any) => React.createElement('button', { ...props }, children), + TooltipPortal: ({ children }: any) => React.createElement('div', { 'data-portal': true }, children), + TooltipPositioner: ({ children }: any) => React.createElement('div', { 'data-positioner': true }, children), + TooltipPopup: ({ children }: any) => React.createElement('div', { role: 'tooltip', ...props }, children), + + async testCompoundStructure() { + const tooltipTester = testCompoundComponent({ + rootComponent: 'Tooltip', + requiredChildren: ['TooltipTrigger', 'TooltipPopup'], + provider: this.TooltipProvider, + }); + + const ui = ( + + Hover me + + + Tooltip content + + + + ); + + const utils = tooltipTester.testBasicRender(ui); + return utils.renderResult; + }, + + async testInteractions() { + const result = await this.testCompoundStructure(); + const utils = createHeadlessTestUtils(result); + + const trigger = result.getByText('Hover me'); + + // Test hover interaction + await utils.testHoverInteraction({ + trigger, + expectedContent: 'Tooltip content', + shouldAppear: true, + }); + + // Test focus interaction + await utils.testFocusInteraction({ + trigger, + expectedContent: 'Tooltip content', + shouldOpen: true, + }); + }, + + async testEventHandling() { + const onOpenChange = vi.fn(); + const { render } = createRenderer(); + + const result = render( + + + Trigger + + + Content + + + + + ); + + const eventRunner = createEventTestRunner(result); + const trigger = result.getByText('Trigger'); + + await eventRunner.testAsyncEventHandling({ + element: trigger, + eventType: 'mouseenter', + asyncHandler: async () => { + expect(onOpenChange).toHaveBeenCalledWith(true); + }, + }); + }, +}; + +/** + * Example: Testing a Menu compound component with keyboard navigation + */ +export const menuExample = { + // Mock Menu components + MenuRoot: ({ children }: any) => React.createElement('div', { role: 'menu' }, children), + MenuTrigger: ({ children, ...props }: any) => React.createElement('button', { ...props }, children), + MenuItem: ({ children, ...props }: any) => React.createElement('div', { role: 'menuitem', tabIndex: 0, ...props }, children), + + async testKeyboardNavigation() { + const { render } = createRenderer(); + + const result = render( + + Menu + Item 1 + Item 2 + Item 3 + + ); + + const utils = createHeadlessTestUtils(result); + const menu = result.getByRole('menu'); + + // Test keyboard patterns specific to menus + await testKeyboardPatterns(menu, KEYBOARD_PATTERNS.MENU); + }, + + async testEventPrevention() { + const { render } = createRenderer(); + const onClick = vi.fn(); + + const result = render( + + + Preventable Item + + + ); + + const eventRunner = createEventTestRunner(result); + const menuItem = result.getByText('Preventable Item'); + + // Test that events can be prevented + await eventRunner.testEventPrevention({ + element: menuItem, + eventType: 'click', + handler: onClick, + preventHandler: true, + }); + }, +}; + +/** + * Example: Testing accessibility patterns + */ +export const accessibilityExample = { + async testCommonA11yPatterns() { + const { render } = createRenderer(); + + const result = render( +
+ Click me +
+ ); + + const element = result.getByRole('button'); + const a11yRunner = createA11yTestRunner(result, { + keyboardNavigation: true, + screenReader: true, + focusManagement: true, + }); + + const testResults = await a11yRunner.runAllTests(element); + console.log('A11y test results:', testResults); + }, +}; + +/** + * Example: Testing performance + */ +export const performanceExample = { + HeavyComponent: ({ items = [] }: { items?: any[] }) => + React.createElement('div', {}, + items.map((item, index) => + React.createElement('div', { key: index }, `Item ${index}: ${item}`) + ) + ), + + async testRenderPerformance() { + const largeDataSet = Array.from({ length: 1000 }, (_, i) => `Data ${i}`); + + await testComponentPerformance({ + Component: this.HeavyComponent, + props: { items: largeDataSet }, + thresholds: { + maxRenderTime: 200, // Allow more time for heavy component + maxUpdateTime: 100, + maxMemoryUsage: 20, + }, + }); + }, +}; diff --git a/packages/test/src/examples/real-component-tests.ts b/packages/test/src/examples/real-component-tests.ts new file mode 100644 index 00000000..0aa77a82 --- /dev/null +++ b/packages/test/src/examples/real-component-tests.ts @@ -0,0 +1,272 @@ +/** + * Real-world examples of testing Flippo headless components + * These examples show how to test actual patterns used in your component library + */ + +import * as React from 'react'; +import { vi, describe, it } from 'vitest'; + +import { + createRenderer, + createHeadlessTestUtils, + testCompoundPatterns, + testAriaRelationships, + createA11yTestRunner, + testComponentPerformance, +} from '../index'; + +/** + * Example: Testing Avatar component with image loading states + */ +export function testAvatarComponent() { + describe('Avatar Component', () => { + const { render } = createRenderer(); + + it('should handle image loading states', async () => { + const onImageLoad = vi.fn(); + const onImageError = vi.fn(); + + const result = render( +
{/* Mock Avatar.Root */} + Test Avatar +
Fallback
{/* Mock Avatar.Fallback */} +
+ ); + + const utils = createHeadlessTestUtils(result); + const img = result.getByRole('img'); + + // Test successful image load + await utils.testEventHandler({ + element: img, + event: 'load', + handler: onImageLoad, + }); + + // Test accessibility + utils.testAccessibilityAttributes(img, { + 'alt': 'Test Avatar', + 'aria-hidden': null, // Should not have aria-hidden + }); + }); + + it('should meet performance requirements', async () => { + const MockAvatar = ({ src, alt }: any) => + React.createElement('img', { src, alt, role: 'img' }); + + await testComponentPerformance({ + Component: MockAvatar, + props: { + src: 'test-image.jpg', + alt: 'Performance test avatar', + }, + thresholds: { + maxRenderTime: 20, // Very fast for simple image + maxUpdateTime: 10, + }, + }); + }); + }); +} + +/** + * Example: Testing Dialog compound component + */ +export function testDialogComponent() { + describe('Dialog Component', () => { + const dialogTester = testCompoundPatterns().dialog; + + it('should handle compound structure correctly', async () => { + const MockDialog = { + Root: ({ children }: any) => React.createElement('div', { 'data-dialog-root': true }, children), + Trigger: ({ children, ...props }: any) => React.createElement('button', { ...props }, children), + Portal: ({ children }: any) => React.createElement('div', { 'data-portal': true }, children), + Popup: ({ children, ...props }: any) => React.createElement('div', { role: 'dialog', ...props }, children), + }; + + const ui = ( + + Open Dialog + + Dialog Content + + + ); + + const utils = await dialogTester.testPartConnections(ui); + + // Test ARIA relationships + await testAriaRelationships({ + renderResult: utils.renderResult, + relationships: [ + { + source: 'button', // selector + target: '[role="dialog"]', // selector + attribute: 'aria-controls', + }, + ], + }); + }); + + it('should handle keyboard interactions', async () => { + const { render } = createRenderer(); + + const result = render( +
+ + + +
+ ); + + const utils = createHeadlessTestUtils(result); + const dialog = result.getByRole('dialog'); + + // Test escape key closes dialog + await utils.testKeyboardNavigation({ + trigger: dialog, + key: 'Escape', + expectFocus: false, + }); + }); + }); +} + +/** + * Example: Testing Menu component with complex interactions + */ +export function testMenuComponent() { + describe('Menu Component', () => { + it('should handle arrow key navigation', async () => { + const { render } = createRenderer(); + + const result = render( +
+
Item 1
+
Item 2
+
Item 3
+
+ ); + + const utils = createHeadlessTestUtils(result); + const firstItem = result.getByTestId('item-1'); + const secondItem = result.getByTestId('item-2'); + + // Test arrow down navigation + await utils.testKeyboardNavigation({ + trigger: firstItem, + expectedTarget: secondItem, + key: 'ArrowDown', + }); + }); + + it('should pass accessibility audit', async () => { + const { render } = createRenderer(); + + const result = render( +
+ + +
+ ); + + const menu = result.getByRole('menu'); + const a11yRunner = createA11yTestRunner(result, { + keyboardNavigation: true, + screenReader: true, + }); + + const results = await a11yRunner.runAllTests(menu); + expect(results.every(r => r.passed)).toBe(true); + }); + }); +} + +/** + * Example: Testing form components + */ +export function testFormComponents() { + describe('Form Components', () => { + it('should handle field validation', async () => { + const { render } = createRenderer(); + const onInvalid = vi.fn(); + + const result = render( +
+
{/* Mock Field.Root */} + + + +
+
+ ); + + const utils = createHeadlessTestUtils(result); + const emailInput = result.getByTestId('email-input'); + + // Test validation + await utils.testEventHandler({ + element: emailInput, + event: 'invalid', + handler: onInvalid, + }); + + // Test ARIA relationships + await testAriaRelationships({ + renderResult: result, + relationships: [ + { + source: 'email-input', + target: 'email-error', + attribute: 'aria-describedby', + }, + ], + }); + }); + }); +} + +/** + * Example: Testing components with floating UI + */ +export function testFloatingComponents() { + describe('Floating Components', () => { + it('should position popover correctly', async () => { + const { render } = createRenderer(); + + const result = render( +
+ +
+ Popup content +
+
+ ); + + const popup = result.getByTestId('popup'); + + // Custom matcher для проверки позиционирования + expect(popup).toBeProperlyPositioned({ + top: 100, + left: 50, + }); + }); + }); +} diff --git a/packages/test/src/examples/tooltip-integration.test.ts b/packages/test/src/examples/tooltip-integration.test.ts new file mode 100644 index 00000000..f34ca932 --- /dev/null +++ b/packages/test/src/examples/tooltip-integration.test.ts @@ -0,0 +1,195 @@ +/** + * Example integration test showing how to use @flippo/internal-test-utils + * with actual Tooltip component from the headless components package + */ + +import { describe, it, vi, beforeEach, afterEach } from 'vitest'; +import { + createRenderer, + createHeadlessTestUtils, + testAriaRelationships, + createA11yTestRunner, + testCommonEventPatterns, + expect, +} from '../index'; + +// This would normally be imported from your actual component package: +// import { Tooltip } from '@flippo-ui/headless-components/tooltip'; + +/** + * Mock Tooltip components for demonstration + * In real usage, you would import these from your component library + */ +const MockTooltip = { + Provider: ({ children }: any) =>
{children}
, + Root: ({ children, onOpenChange }: any) => ( +
+ {children} +
+ ), + Trigger: ({ children, ...props }: any) => ( + + ), + Portal: ({ children }: any) => ( +
{children}
+ ), + Positioner: ({ children }: any) => ( +
{children}
+ ), + Popup: ({ children, ...props }: any) => ( + + ), +}; + +describe('Tooltip Integration Test', () => { + const { render } = createRenderer(); + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should render and show/hide on hover', async () => { + const onOpenChange = vi.fn(); + + const result = render( + + + Hover me + + + Tooltip content + + + + + ); + + const utils = createHeadlessTestUtils(result); + const trigger = result.getByTestId('tooltip-trigger'); + + // Test hover interaction using headless utilities + await utils.testHoverInteraction({ + trigger, + expectedContent: 'Tooltip content', + shouldAppear: true, + }); + + // Verify onOpenChange was called + expect(onOpenChange).toHaveBeenCalledWith(true); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('should have proper accessibility attributes', async () => { + const result = render( + + + Accessible trigger + + + Accessible content + + + + + ); + + // Test ARIA relationships between trigger and popup + await testAriaRelationships({ + renderResult: result, + relationships: [ + { + source: 'tooltip-trigger', + target: 'tooltip-popup', + attribute: 'aria-describedby', + }, + ], + }); + + // Run comprehensive accessibility tests + const popup = result.getByTestId('tooltip-popup'); + const a11yRunner = createA11yTestRunner(result); + + const results = await a11yRunner.runAllTests(popup); + expect(results.every(r => r.passed)).toBe(true); + }); + + it('should handle keyboard interactions', async () => { + const result = render( + + + Keyboard accessible + + + Keyboard content + + + + + ); + + const utils = createHeadlessTestUtils(result); + const trigger = result.getByTestId('tooltip-trigger'); + + // Test focus interaction + await utils.testFocusInteraction({ + trigger, + expectedContent: 'Keyboard content', + shouldOpen: true, + }); + + // Test escape key closes tooltip + const patterns = testCommonEventPatterns(); + await patterns.escapeKey({ + element: trigger, + onEscape: vi.fn(), + }); + }); + + it('should use custom matchers', () => { + const result = render( + + + + + + Test content + + + + + ); + + const trigger = result.getByTestId('tooltip-trigger'); + const popup = result.getByTestId('tooltip-popup'); + + // Use custom matchers specific to headless components + expect(trigger).toHaveHeadlessUIAttributes({ + 'aria-expanded': false, + 'data-testid': 'tooltip-trigger', + }); + + expect(popup).toHaveHeadlessUIAttributes({ + 'role': 'tooltip', + 'id': 'tooltip-popup', + }); + + // Test compound structure + expect(result.container).toHaveCompoundParts([ + 'tooltip-trigger', + 'tooltip-popup' + ]); + }); +}); diff --git a/packages/test/src/index.ts b/packages/test/src/index.ts new file mode 100644 index 00000000..bb2f913b --- /dev/null +++ b/packages/test/src/index.ts @@ -0,0 +1,80 @@ +/** + * @flippo/internal-test-utils + * + * Internal testing utilities for Flippo headless UI components. + * Provides a comprehensive suite of tools for testing React components + * with focus on headless UI patterns, accessibility, and performance. + */ + +// Core renderer and utilities +export { createRenderer, defaultRenderer, rtlRenderer, performanceRenderer } from './renderer/createRenderer'; + +// Setup and initialization +export { init, quickSetup } from './setup'; +export { setupVitest, setupCompoundComponentTesting, setupPerformanceTesting, setupDebugMode } from './setup/vitest'; + +// Testing utilities +export { HeadlessTestUtils, createHeadlessTestUtils, testCompoundComponent, testInteractionScenarios, testAsChildPattern } from './utils/headless'; +export { AccessibilityTestRunner, createA11yTestRunner, testAccessibility, testKeyboardPatterns, KEYBOARD_PATTERNS } from './utils/accessibility'; +export { PerformanceTestRunner, createPerformanceRunner, testComponentPerformance, testStressPerformance } from './utils/performance'; +export { EventTestRunner, createEventTestRunner, testCommonEventPatterns, createMockHeadlessUIEvent, testEventHandlerMerging } from './utils/events'; +export { CompoundComponentTester, createCompoundTester, testCompoundPatterns, testAriaRelationships, testStateSynchronization } from './utils/compound'; + +// Matchers and assertions +export { initMatchers, headlessMatchers, waitForPosition, waitForStateChange } from './matchers'; + +// Providers +export { DirectionProvider } from './renderer/providers/DirectionProvider'; +export { PerformanceMonitor, PerformanceContext, usePerformanceMetrics } from './renderer/providers/PerformanceMonitor'; +export { TestIdProvider, useTestId } from './renderer/providers/TestIdProvider'; + +// Types +export type { + RendererOptions, + HeadlessRenderResult, + HeadlessMatcherOptions, + CompoundTestConfig, + InteractionScenario, + A11yTestOptions, + PerformanceThresholds, + TestEnvironmentConfig, +} from './types'; + +// Re-export commonly used testing library utilities +export { + screen, + fireEvent, + waitFor, + waitForElementToBeRemoved, + act, + cleanup, +} from '@testing-library/react'; + +export { userEvent } from '@testing-library/user-event'; +export { vi, expect, describe, it, test, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; + +/** + * Convenience function to create a complete test environment + */ +export function createTestEnvironment(config?: import('./types').TestEnvironmentConfig) { + // Initialize environment + const { init } = require('./setup'); + init(config); + + // Create renderer + const { createRenderer } = require('./renderer/createRenderer'); + const renderer = createRenderer({ + direction: config?.defaultDirection, + enablePerformanceMonitoring: config?.enablePerformanceMonitoring, + }); + + return { + render: renderer.render, + cleanup: renderer.cleanup, + }; +} + +/** + * Version information + */ +export const version = '1.0.0'; diff --git a/packages/test/src/matchers/index.ts b/packages/test/src/matchers/index.ts new file mode 100644 index 00000000..31ed4b79 --- /dev/null +++ b/packages/test/src/matchers/index.ts @@ -0,0 +1,229 @@ +import { expect } from 'vitest'; +import { waitFor } from '@testing-library/react'; +import type { HeadlessMatcherOptions } from '../types'; + +/** + * Custom matchers for Flippo headless components + */ +export const headlessMatchers = { + /** + * Tests that an element has proper headless UI accessibility attributes + */ + toHaveHeadlessUIAttributes( + received: HTMLElement, + expectedAttributes?: Record, + options: HeadlessMatcherOptions = {} + ) { + const { checkAccessibility = true, validateAria = true } = options; + + if (checkAccessibility) { + // Check for basic accessibility requirements + const hasAccessibleName = received.hasAttribute('aria-label') || + received.hasAttribute('aria-labelledby') || + received.textContent?.trim(); + + if (!hasAccessibleName && received.tagName !== 'DIV') { + return { + message: () => `Expected element to have an accessible name (aria-label, aria-labelledby, or text content)`, + pass: false, + }; + } + } + + if (validateAria && expectedAttributes) { + const missingAttributes: string[] = []; + const incorrectValues: string[] = []; + + Object.entries(expectedAttributes).forEach(([attr, expectedValue]) => { + const actualValue = received.getAttribute(attr); + + if (expectedValue === null) { + if (actualValue !== null) { + incorrectValues.push(`${attr}: expected null, got "${actualValue}"`); + } + } else if (typeof expectedValue === 'boolean') { + const boolValue = actualValue === 'true'; + if (boolValue !== expectedValue) { + incorrectValues.push(`${attr}: expected ${expectedValue}, got ${boolValue}`); + } + } else if (actualValue !== expectedValue) { + if (actualValue === null) { + missingAttributes.push(attr); + } else { + incorrectValues.push(`${attr}: expected "${expectedValue}", got "${actualValue}"`); + } + } + }); + + if (missingAttributes.length > 0 || incorrectValues.length > 0) { + const errors = [ + ...missingAttributes.map(attr => `Missing attribute: ${attr}`), + ...incorrectValues, + ]; + + return { + message: () => `Accessibility attributes mismatch:\n${errors.join('\n')}`, + pass: false, + }; + } + } + + return { + message: () => `Expected element to fail headless UI accessibility check`, + pass: true, + }; + }, + + /** + * Tests that an element is properly focused (including virtual focus) + */ + toHaveHeadlessFocus(received: HTMLElement) { + const hasDOMFocus = document.activeElement === received; + const hasVirtualFocus = received.hasAttribute('data-focus') || + received.hasAttribute('aria-current') || + received.classList.contains('focus') || + received.classList.contains('focused'); + + const pass = hasDOMFocus || hasVirtualFocus; + + return { + message: () => + pass + ? `Expected element not to have headless focus` + : `Expected element to have headless focus (DOM focus or virtual focus indicators)`, + pass, + }; + }, + + /** + * Tests that a compound component has all required parts + */ + toHaveCompoundParts(received: HTMLElement, expectedParts: string[]) { + const missingParts: string[] = []; + + expectedParts.forEach(part => { + const partSelector = `[data-flippo-component*="${part}"], [data-testid*="${part.toLowerCase()}"]`; + const partElement = received.querySelector(partSelector); + + if (!partElement) { + missingParts.push(part); + } + }); + + const pass = missingParts.length === 0; + + return { + message: () => + pass + ? `Expected compound component not to have all required parts` + : `Expected compound component to have parts: ${missingParts.join(', ')}`, + pass, + }; + }, + + /** + * Tests that an element has proper event handler attributes + */ + toHaveEventHandler(received: HTMLElement, eventType: string) { + const eventProp = `on${eventType.charAt(0).toUpperCase()}${eventType.slice(1)}`; + + // Check if element has the event handler in its props + const hasHandler = (received as any)[eventProp] || + received.hasAttribute(`data-${eventType}`) || + received.hasAttribute(`aria-${eventType}`); + + return { + message: () => + hasHandler + ? `Expected element not to have ${eventType} event handler` + : `Expected element to have ${eventType} event handler`, + pass: Boolean(hasHandler), + }; + }, + + /** + * Tests that an element prevents the default headless UI handler + */ + toPreventHeadlessUIHandler(received: any) { + const hasPreventMethod = typeof received?.preventHeadlessUIHandler === 'function'; + const isPrevented = received?.headlessUIHandlerPrevented === true; + + return { + message: () => + hasPreventMethod && isPrevented + ? `Expected event not to prevent headless UI handler` + : `Expected event to prevent headless UI handler`, + pass: hasPreventMethod && isPrevented, + }; + }, + + /** + * Tests that an element is properly positioned (for floating elements) + */ + toBeProperlyPositioned(received: HTMLElement, expectedPosition?: { top?: number; left?: number }) { + const computedStyle = window.getComputedStyle(received); + const position = computedStyle.position; + + const isPositioned = ['absolute', 'fixed', 'relative'].includes(position); + + if (!isPositioned) { + return { + message: () => `Expected element to be positioned (absolute, fixed, or relative), got ${position}`, + pass: false, + }; + } + + if (expectedPosition) { + const rect = received.getBoundingClientRect(); + const { top, left } = expectedPosition; + + if (top !== undefined && Math.abs(rect.top - top) > 1) { + return { + message: () => `Expected element top position to be ${top}, got ${rect.top}`, + pass: false, + }; + } + + if (left !== undefined && Math.abs(rect.left - left) > 1) { + return { + message: () => `Expected element left position to be ${left}, got ${rect.left}`, + pass: false, + }; + } + } + + return { + message: () => `Expected element not to be properly positioned`, + pass: true, + }; + }, +}; + +/** + * Initialize custom matchers for Vitest + */ +export function initMatchers() { + expect.extend(headlessMatchers); +} + +/** + * Helper function to wait for element to be positioned + */ +export async function waitForPosition(element: HTMLElement, timeout = 1000) { + await waitFor(() => { + expect(element).toBeProperlyPositioned(); + }, { timeout }); +} + +/** + * Helper function to wait for component state changes + */ +export async function waitForStateChange( + checkFunction: () => boolean | Promise, + timeout = 1000 +) { + await waitFor(async () => { + const result = await checkFunction(); + expect(result).toBe(true); + }, { timeout }); +} diff --git a/packages/test/src/matchers/types.ts b/packages/test/src/matchers/types.ts new file mode 100644 index 00000000..206d367e --- /dev/null +++ b/packages/test/src/matchers/types.ts @@ -0,0 +1,76 @@ +import type { HeadlessMatcherOptions } from '../types'; + +/** + * Custom matcher definitions for TypeScript + */ +declare module 'vitest' { + interface Assertion { + /** + * Tests that an element has proper headless UI accessibility attributes + */ + toHaveHeadlessUIAttributes( + expectedAttributes?: Record, + options?: HeadlessMatcherOptions + ): T; + + /** + * Tests that an element is properly focused (including virtual focus) + */ + toHaveHeadlessFocus(): T; + + /** + * Tests that a compound component has all required parts + */ + toHaveCompoundParts(expectedParts: string[]): T; + + /** + * Tests that an element has proper event handler attributes + */ + toHaveEventHandler(eventType: string): T; + + /** + * Tests that an element prevents the default headless UI handler + */ + toPreventHeadlessUIHandler(): T; + + /** + * Tests that an element is properly positioned (for floating elements) + */ + toBeProperlyPositioned(expectedPosition?: { top?: number; left?: number }): T; + } + + interface AsymmetricMatchersContaining { + /** + * Tests that an element has proper headless UI accessibility attributes + */ + toHaveHeadlessUIAttributes( + expectedAttributes?: Record, + options?: HeadlessMatcherOptions + ): any; + + /** + * Tests that an element is properly focused (including virtual focus) + */ + toHaveHeadlessFocus(): any; + + /** + * Tests that a compound component has all required parts + */ + toHaveCompoundParts(expectedParts: string[]): any; + + /** + * Tests that an element has proper event handler attributes + */ + toHaveEventHandler(eventType: string): any; + + /** + * Tests that an element prevents the default headless UI handler + */ + toPreventHeadlessUIHandler(): any; + + /** + * Tests that an element is properly positioned (for floating elements) + */ + toBeProperlyPositioned(expectedPosition?: { top?: number; left?: number }): any; + } +} diff --git a/packages/test/src/renderer/createRenderer.ts b/packages/test/src/renderer/createRenderer.ts new file mode 100644 index 00000000..eb4697a4 --- /dev/null +++ b/packages/test/src/renderer/createRenderer.ts @@ -0,0 +1,133 @@ +import * as React from 'react'; +import { render as testingLibraryRender, cleanup } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi } from 'vitest'; + +import type { RendererOptions, HeadlessRenderResult } from '../types'; +import { DirectionProvider } from './providers/DirectionProvider'; +import { PerformanceMonitor } from './providers/PerformanceMonitor'; +import { TestIdProvider } from './providers/TestIdProvider'; +import { afterEach } from 'vitest'; + +/** + * Creates a standardized renderer for Flippo headless components + * + * @param options - Configuration options for the renderer + * @returns Object with render function and cleanup utilities + */ +export function createRenderer(options: RendererOptions = {}) { + const { + wrapper: CustomWrapper, + autoCleanup = true, + enablePerformanceMonitoring = false, + direction = 'ltr', + ...renderOptions + } = options; + + // Setup automatic cleanup if enabled + if (autoCleanup) { + afterEach(() => { + cleanup(); + }); + } + + /** + * Render a React component with Flippo headless UI providers + */ + function render(ui: React.ReactElement, testOptions: Partial = {}): HeadlessRenderResult { + const { + wrapper: TestWrapper = React.Fragment, + direction: testDirection = direction, + enablePerformanceMonitoring: testPerformanceMonitoring = enablePerformanceMonitoring, + ...testRenderOptions + } = testOptions; + + // Create wrapper component with all necessary providers + const AllProviders: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const wrappedChildren = CustomWrapper + ? React.createElement(CustomWrapper, { children }) + : children; + + const testWrapper = React.createElement(TestWrapper, { children: wrappedChildren }); + + const content = testPerformanceMonitoring + ? React.createElement(PerformanceMonitor, { children: testWrapper }) + : testWrapper; + + const withTestId = React.createElement(TestIdProvider, { children: content }); + + return React.createElement( + DirectionProvider, + { direction: testDirection, children: withTestId } + ); + }; + + const renderResult = testingLibraryRender(ui, { + wrapper: AllProviders, + ...renderOptions, + ...testRenderOptions, + }); + + // Create user event instance + const user = userEvent.setup({ + advanceTimers: vi.advanceTimersByTime, + pointerEventsCheck: 0, // Disable pointer events check for headless components + }); + + // Enhanced utilities for headless components + const getByTestIdEnhanced = (id: string) => { + const element = renderResult.queryByTestId(id); + if (!element) { + throw new Error( + `Unable to find element with test id: ${id}\n\n` + + `Available test ids:\n${ + Array.from(renderResult.container.querySelectorAll('[data-testid]')) + .map(el => ` - ${el.getAttribute('data-testid')}`) + .join('\n') + }` + ); + } + return element; + }; + + const queryByTestIdEnhanced = (id: string) => renderResult.queryByTestId(id); + + const findByTestIdEnhanced = async (id: string) => { + const element = await renderResult.findByTestId(id); + if (!element) { + throw new Error(`Unable to find element with test id: ${id}`); + } + return element; + }; + + return { + ...renderResult, + user, + getByTestIdEnhanced, + queryByTestIdEnhanced, + findByTestIdEnhanced, + }; + } + + return { + render, + cleanup, + }; +} + +/** + * Default renderer instance for simple use cases + */ +export const defaultRenderer = createRenderer(); + +/** + * Renderer with RTL support enabled + */ +export const rtlRenderer = createRenderer({ direction: 'rtl' }); + +/** + * Renderer with performance monitoring enabled + */ +export const performanceRenderer = createRenderer({ + enablePerformanceMonitoring: true +}); diff --git a/packages/test/src/renderer/providers/DirectionProvider.ts b/packages/test/src/renderer/providers/DirectionProvider.ts new file mode 100644 index 00000000..210db88e --- /dev/null +++ b/packages/test/src/renderer/providers/DirectionProvider.ts @@ -0,0 +1,31 @@ +import * as React from 'react'; + +export interface DirectionProviderProps { + children: React.ReactNode; + direction: 'ltr' | 'rtl'; +} + +/** + * Direction provider for testing RTL/LTR layouts + */ +export function DirectionProvider({ children, direction }: DirectionProviderProps) { + React.useEffect(() => { + // Set the HTML dir attribute for testing + document.documentElement.dir = direction; + + return () => { + // Reset to default + document.documentElement.dir = 'ltr'; + }; + }, [direction]); + + // Create a context that components can use if needed + return React.createElement( + 'div', + { + dir: direction, + 'data-testid': 'direction-provider', + }, + children + ); +} diff --git a/packages/test/src/renderer/providers/DirectionProvider.tsx b/packages/test/src/renderer/providers/DirectionProvider.tsx new file mode 100644 index 00000000..210db88e --- /dev/null +++ b/packages/test/src/renderer/providers/DirectionProvider.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; + +export interface DirectionProviderProps { + children: React.ReactNode; + direction: 'ltr' | 'rtl'; +} + +/** + * Direction provider for testing RTL/LTR layouts + */ +export function DirectionProvider({ children, direction }: DirectionProviderProps) { + React.useEffect(() => { + // Set the HTML dir attribute for testing + document.documentElement.dir = direction; + + return () => { + // Reset to default + document.documentElement.dir = 'ltr'; + }; + }, [direction]); + + // Create a context that components can use if needed + return React.createElement( + 'div', + { + dir: direction, + 'data-testid': 'direction-provider', + }, + children + ); +} diff --git a/packages/test/src/renderer/providers/PerformanceMonitor.ts b/packages/test/src/renderer/providers/PerformanceMonitor.ts new file mode 100644 index 00000000..d84f7b76 --- /dev/null +++ b/packages/test/src/renderer/providers/PerformanceMonitor.ts @@ -0,0 +1,54 @@ +import * as React from 'react'; + +export interface PerformanceMonitorProps { + children: React.ReactNode; +} + +export interface PerformanceMetrics { + renderTime: number; + updateCount: number; + lastUpdateTime: number; +} + +/** + * Performance monitoring context for testing + */ +export const PerformanceContext = React.createContext(null); + +/** + * Performance monitor provider for testing component performance + */ +export function PerformanceMonitor({ children }: PerformanceMonitorProps) { + const [metrics, setMetrics] = React.useState({ + renderTime: 0, + updateCount: 0, + lastUpdateTime: 0, + }); + + const startTime = React.useRef(performance.now()); + const updateCount = React.useRef(0); + + React.useEffect(() => { + const renderTime = performance.now() - startTime.current; + updateCount.current += 1; + + setMetrics({ + renderTime, + updateCount: updateCount.current, + lastUpdateTime: performance.now(), + }); + }); + + return React.createElement( + PerformanceContext.Provider, + { value: metrics }, + children + ); +} + +/** + * Hook to access performance metrics in tests + */ +export function usePerformanceMetrics(): PerformanceMetrics | null { + return React.useContext(PerformanceContext); +} diff --git a/packages/test/src/renderer/providers/PerformanceMonitor.tsx b/packages/test/src/renderer/providers/PerformanceMonitor.tsx new file mode 100644 index 00000000..d84f7b76 --- /dev/null +++ b/packages/test/src/renderer/providers/PerformanceMonitor.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; + +export interface PerformanceMonitorProps { + children: React.ReactNode; +} + +export interface PerformanceMetrics { + renderTime: number; + updateCount: number; + lastUpdateTime: number; +} + +/** + * Performance monitoring context for testing + */ +export const PerformanceContext = React.createContext(null); + +/** + * Performance monitor provider for testing component performance + */ +export function PerformanceMonitor({ children }: PerformanceMonitorProps) { + const [metrics, setMetrics] = React.useState({ + renderTime: 0, + updateCount: 0, + lastUpdateTime: 0, + }); + + const startTime = React.useRef(performance.now()); + const updateCount = React.useRef(0); + + React.useEffect(() => { + const renderTime = performance.now() - startTime.current; + updateCount.current += 1; + + setMetrics({ + renderTime, + updateCount: updateCount.current, + lastUpdateTime: performance.now(), + }); + }); + + return React.createElement( + PerformanceContext.Provider, + { value: metrics }, + children + ); +} + +/** + * Hook to access performance metrics in tests + */ +export function usePerformanceMetrics(): PerformanceMetrics | null { + return React.useContext(PerformanceContext); +} diff --git a/packages/test/src/renderer/providers/TestIdProvider.ts b/packages/test/src/renderer/providers/TestIdProvider.ts new file mode 100644 index 00000000..2d1f4e47 --- /dev/null +++ b/packages/test/src/renderer/providers/TestIdProvider.ts @@ -0,0 +1,51 @@ +import * as React from 'react'; + +export interface TestIdProviderProps { + children: React.ReactNode; + prefix?: string; +} + +export interface TestIdContext { + generateTestId: (componentName: string, part?: string) => string; + prefix: string; +} + +const TestIdContext = React.createContext(null); + +/** + * Provider for generating consistent test IDs across components + */ +export function TestIdProvider({ children, prefix = 'flippo-test' }: TestIdProviderProps) { + const generateTestId = React.useCallback( + (componentName: string, part?: string) => { + const baseName = componentName.toLowerCase().replace(/([A-Z])/g, '-$1').replace(/^-/, ''); + return part ? `${prefix}-${baseName}-${part}` : `${prefix}-${baseName}`; + }, + [prefix] + ); + + const contextValue = React.useMemo( + () => ({ + generateTestId, + prefix, + }), + [generateTestId, prefix] + ); + + return React.createElement( + TestIdContext.Provider, + { value: contextValue }, + children + ); +} + +/** + * Hook to access test ID utilities + */ +export function useTestId(): TestIdContext { + const context = React.useContext(TestIdContext); + if (!context) { + throw new Error('useTestId must be used within a TestIdProvider'); + } + return context; +} \ No newline at end of file diff --git a/packages/test/src/renderer/providers/TestIdProvider.tsx b/packages/test/src/renderer/providers/TestIdProvider.tsx new file mode 100644 index 00000000..2d1f4e47 --- /dev/null +++ b/packages/test/src/renderer/providers/TestIdProvider.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; + +export interface TestIdProviderProps { + children: React.ReactNode; + prefix?: string; +} + +export interface TestIdContext { + generateTestId: (componentName: string, part?: string) => string; + prefix: string; +} + +const TestIdContext = React.createContext(null); + +/** + * Provider for generating consistent test IDs across components + */ +export function TestIdProvider({ children, prefix = 'flippo-test' }: TestIdProviderProps) { + const generateTestId = React.useCallback( + (componentName: string, part?: string) => { + const baseName = componentName.toLowerCase().replace(/([A-Z])/g, '-$1').replace(/^-/, ''); + return part ? `${prefix}-${baseName}-${part}` : `${prefix}-${baseName}`; + }, + [prefix] + ); + + const contextValue = React.useMemo( + () => ({ + generateTestId, + prefix, + }), + [generateTestId, prefix] + ); + + return React.createElement( + TestIdContext.Provider, + { value: contextValue }, + children + ); +} + +/** + * Hook to access test ID utilities + */ +export function useTestId(): TestIdContext { + const context = React.useContext(TestIdContext); + if (!context) { + throw new Error('useTestId must be used within a TestIdProvider'); + } + return context; +} \ No newline at end of file diff --git a/packages/test/src/setup/global.ts b/packages/test/src/setup/global.ts new file mode 100644 index 00000000..1650349f --- /dev/null +++ b/packages/test/src/setup/global.ts @@ -0,0 +1,16 @@ +/** + * Global setup file that can be imported in Vitest setupFiles + */ + +import { init, quickSetup } from './index'; + +// Default initialization for most common use case +init({ + enableA11yTesting: true, + enablePerformanceMonitoring: process.env.NODE_ENV === 'development', + debug: process.env.DEBUG_TESTS === 'true', + defaultDirection: 'ltr', +}); + +// Export quick setups for different scenarios +export { quickSetup }; diff --git a/packages/test/src/setup/index.ts b/packages/test/src/setup/index.ts new file mode 100644 index 00000000..ad52f6c0 --- /dev/null +++ b/packages/test/src/setup/index.ts @@ -0,0 +1,104 @@ +/** + * Main setup module for @flippo/internal-test-utils + * + * This module provides a one-stop setup for all Flippo headless component testing needs. + */ + +import { setupVitest, setupCompoundComponentTesting, setupPerformanceTesting, setupDebugMode } from './vitest'; +import { initMatchers } from '../matchers'; + +import type { TestEnvironmentConfig } from '../types'; + +/** + * Initialize complete testing environment for Flippo headless components + */ +export function init(config: TestEnvironmentConfig = {}) { + // Setup base Vitest environment + setupVitest(config); + + // Setup compound component testing if needed + if (config.enableA11yTesting) { + setupCompoundComponentTesting(); + } + + // Setup performance testing if enabled + if (config.enablePerformanceMonitoring) { + setupPerformanceTesting(config.performanceThresholds); + } + + // Setup debug mode if enabled + if (config.debug) { + setupDebugMode(); + } + + // Initialize custom matchers + initMatchers(); + + // Register any custom matchers provided in config + if (config.customMatchers && config.customMatchers.length > 0) { + console.log(`Registered ${config.customMatchers.length} custom matchers`); + } +} + +/** + * Quick setup for different testing scenarios + */ +export const quickSetup = { + /** + * Basic setup for unit testing + */ + unit: () => init({ + enablePerformanceMonitoring: false, + enableA11yTesting: true, + debug: false, + }), + + /** + * Setup for integration testing + */ + integration: () => init({ + enablePerformanceMonitoring: true, + enableA11yTesting: true, + debug: false, + }), + + /** + * Setup for performance testing + */ + performance: () => init({ + enablePerformanceMonitoring: true, + enableA11yTesting: false, + debug: false, + performanceThresholds: { + maxRenderTime: 50, + maxUpdateTime: 25, + maxMemoryUsage: 5, + }, + }), + + /** + * Setup for accessibility testing + */ + accessibility: () => init({ + enablePerformanceMonitoring: false, + enableA11yTesting: true, + debug: true, + }), + + /** + * Setup for debugging + */ + debug: () => init({ + enablePerformanceMonitoring: true, + enableA11yTesting: true, + debug: true, + }), +}; + +// Export setup functions +export { + setupVitest, + setupCompoundComponentTesting, + setupPerformanceTesting, + setupDebugMode, +}; diff --git a/packages/test/src/setup/vitest.ts b/packages/test/src/setup/vitest.ts new file mode 100644 index 00000000..af9f0bf6 --- /dev/null +++ b/packages/test/src/setup/vitest.ts @@ -0,0 +1,199 @@ +import { vi, beforeEach, afterEach } from 'vitest'; +import '@testing-library/jest-dom'; + +import type { TestEnvironmentConfig } from '../types'; +import { initMatchers } from '../matchers'; + +/** + * Global test environment state + */ +let globalConfig: TestEnvironmentConfig = {}; + +/** + * Setup Vitest environment for Flippo headless components testing + */ +export function setupVitest(config: TestEnvironmentConfig = {}) { + globalConfig = { + enablePerformanceMonitoring: false, + enableA11yTesting: true, + defaultDirection: 'ltr', + debug: false, + customMatchers: [], + performanceThresholds: { + maxRenderTime: 100, + maxUpdateTime: 50, + maxMemoryUsage: 10, + }, + ...config, + }; + + // Initialize custom matchers + initMatchers(); + + // Setup global beforeEach hooks + beforeEach(() => { + // Mock performance.now if performance monitoring is enabled + if (globalConfig.enablePerformanceMonitoring) { + vi.spyOn(performance, 'now').mockReturnValue(Date.now()); + } + + // Setup fake timers for consistent timing tests + vi.useFakeTimers(); + + // Mock IntersectionObserver for components that use it + global.IntersectionObserver = vi.fn().mockImplementation((callback, options) => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + root: null, + rootMargin: '', + thresholds: [], + })); + + // Mock ResizeObserver for responsive components + global.ResizeObserver = vi.fn().mockImplementation((callback) => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + })); + + // Mock matchMedia for responsive testing + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), // deprecated + removeListener: vi.fn(), // deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + + // Mock getComputedStyle for styling tests + Object.defineProperty(window, 'getComputedStyle', { + value: () => ({ + getPropertyValue: () => '', + position: 'static', + top: '0px', + left: '0px', + width: '0px', + height: '0px', + }), + }); + + // Setup console spy for debug mode + if (globalConfig.debug) { + vi.spyOn(console, 'warn'); + vi.spyOn(console, 'error'); + } + }); + + // Setup global afterEach hooks + afterEach(() => { + // Restore real timers + vi.useRealTimers(); + + // Clear all mocks + vi.clearAllMocks(); + + // Reset DOM + document.body.innerHTML = ''; + document.head.innerHTML = ''; + + // Reset document direction + document.documentElement.dir = globalConfig.defaultDirection || 'ltr'; + + // Check for console warnings/errors in debug mode + if (globalConfig.debug) { + const consoleWarn = console.warn as any; + const consoleError = console.error as any; + + if (consoleWarn.mock?.calls.length > 0) { + console.log('Console warnings during test:', consoleWarn.mock.calls); + } + + if (consoleError.mock?.calls.length > 0) { + console.log('Console errors during test:', consoleError.mock.calls); + } + } + }); +} + +/** + * Get current global test configuration + */ +export function getTestConfig(): TestEnvironmentConfig { + return globalConfig; +} + +/** + * Update global test configuration + */ +export function updateTestConfig(config: Partial) { + globalConfig = { ...globalConfig, ...config }; +} + +/** + * Setup specific configuration for compound component testing + */ +export function setupCompoundComponentTesting() { + beforeEach(() => { + // Add data attributes for compound component identification + const style = document.createElement('style'); + style.textContent = ` + [data-flippo-component] { + outline: 1px solid rgba(255, 0, 0, 0.1) !important; + } + `; + document.head.appendChild(style); + }); +} + +/** + * Setup performance testing configuration + */ +export function setupPerformanceTesting(thresholds?: TestEnvironmentConfig['performanceThresholds']) { + updateTestConfig({ + enablePerformanceMonitoring: true, + performanceThresholds: thresholds || globalConfig.performanceThresholds, + }); + + // Add performance observation helpers + beforeEach(() => { + // Create performance observer for component measurements + if ('PerformanceObserver' in window) { + const observer = new PerformanceObserver((list) => { + const entries = list.getEntries(); + entries.forEach((entry) => { + if (entry.duration > (globalConfig.performanceThresholds?.maxRenderTime || 100)) { + console.warn(`Performance threshold exceeded: ${entry.name} took ${entry.duration}ms`); + } + }); + }); + + observer.observe({ entryTypes: ['measure'] }); + } + }); +} + +/** + * Setup debugging helpers + */ +export function setupDebugMode() { + updateTestConfig({ debug: true }); + + // Add global debug helpers + (global as any).debugElement = (element: HTMLElement) => { + console.log('Element:', element); + console.log('Attributes:', Array.from(element.attributes).map(attr => `${attr.name}="${attr.value}"`)); + console.log('Computed style:', window.getComputedStyle(element)); + console.log('Event listeners:', (element as any).getEventListeners?.() || 'Not available'); + }; + + (global as any).debugDOM = () => { + console.log('Current DOM:', document.body.innerHTML); + }; +} diff --git a/packages/test/src/test-utils.d.ts b/packages/test/src/test-utils.d.ts new file mode 100644 index 00000000..0b586424 --- /dev/null +++ b/packages/test/src/test-utils.d.ts @@ -0,0 +1,34 @@ +/** + * TypeScript declarations for @flippo/internal-test-utils + */ + +import '@testing-library/jest-dom'; +import './matchers/types'; + +declare global { + namespace globalThis { + var debugElement: (element: HTMLElement) => void; + var debugDOM: () => void; + } + + interface Window { + debugElement: (element: HTMLElement) => void; + debugDOM: () => void; + } + + // Extend HTMLElement with custom properties that might be added by tests + interface HTMLElement { + 'data-flippo-component'?: string; + 'data-focus'?: string; + 'data-testid'?: string; + } + + // Performance memory interface + interface Performance { + memory?: { + usedJSHeapSize: number; + totalJSHeapSize: number; + jsHeapSizeLimit: number; + }; + } +} diff --git a/packages/test/src/types/index.ts b/packages/test/src/types/index.ts new file mode 100644 index 00000000..8615e825 --- /dev/null +++ b/packages/test/src/types/index.ts @@ -0,0 +1,220 @@ +import type * as React from 'react'; +import type { RenderOptions, RenderResult } from '@testing-library/react'; +import type { UserEvent } from '@testing-library/user-event'; + +/** + * Configuration options for the test renderer + */ +export interface RendererOptions extends Omit { + /** + * Custom wrapper component for testing (e.g., providers, contexts) + */ + wrapper?: React.ComponentType<{ children: React.ReactNode }>; + + /** + * Whether to enable automatic cleanup between tests + * @default true + */ + autoCleanup?: boolean; + + /** + * Whether to enable performance monitoring during tests + * @default false + */ + enablePerformanceMonitoring?: boolean; + + /** + * Direction for RTL testing + * @default 'ltr' + */ + direction?: 'ltr' | 'rtl'; +} + +/** + * Enhanced render result with additional utilities for headless components + */ +export interface HeadlessRenderResult extends RenderResult { + /** + * User event instance configured for the test + */ + user: UserEvent; + + /** + * Enhanced getByTestId with better error messages + */ + getByTestIdEnhanced: (id: string) => HTMLElement; + + /** + * Enhanced queryByTestId + */ + queryByTestIdEnhanced: (id: string) => HTMLElement | null; + + /** + * Enhanced findByTestId + */ + findByTestIdEnhanced: (id: string) => Promise; +} + +/** + * Custom matcher options for headless components + */ +export interface HeadlessMatcherOptions { + /** + * Whether to check for accessibility attributes + * @default true + */ + checkAccessibility?: boolean; + + /** + * Whether to validate ARIA attributes + * @default true + */ + validateAria?: boolean; + + /** + * Custom timeout for async operations + * @default 1000 + */ + timeout?: number; +} + +/** + * Configuration for component compound testing + */ +export interface CompoundTestConfig { + /** + * Root component name for error messages + */ + rootComponent: string; + + /** + * Required child components for the compound component + */ + requiredChildren?: string[]; + + /** + * Optional provider component that wraps the compound component + */ + provider?: React.ComponentType<{ children: React.ReactNode }>; +} + +/** + * Test scenario for interaction testing + */ +export interface InteractionScenario { + /** + * Name of the interaction scenario + */ + name: string; + + /** + * Setup function to prepare the test environment + */ + setup: (utils: HeadlessRenderResult) => Promise | void; + + /** + * The actual interaction to perform + */ + interact: (utils: HeadlessRenderResult) => Promise | void; + + /** + * Assertions to verify the outcome + */ + assert: (utils: HeadlessRenderResult) => Promise | void; +} + +/** + * Configuration for accessibility testing + */ +export interface A11yTestOptions { + /** + * Whether to test keyboard navigation + * @default true + */ + keyboardNavigation?: boolean; + + /** + * Whether to test screen reader compatibility + * @default true + */ + screenReader?: boolean; + + /** + * Whether to test focus management + * @default true + */ + focusManagement?: boolean; + + /** + * Whether to test color contrast + * @default false + */ + colorContrast?: boolean; + + /** + * Custom ARIA rules to validate + */ + customAriaRules?: string[]; +} + +/** + * Performance testing thresholds + */ +export interface PerformanceThresholds { + /** + * Maximum render time in milliseconds + * @default 100 + */ + maxRenderTime?: number; + + /** + * Maximum update time in milliseconds + * @default 50 + */ + maxUpdateTime?: number; + + /** + * Maximum memory usage in MB + * @default 10 + */ + maxMemoryUsage?: number; +} + +/** + * Global test environment configuration + */ +export interface TestEnvironmentConfig { + /** + * Whether to enable global performance monitoring + * @default false + */ + enablePerformanceMonitoring?: boolean; + + /** + * Whether to enable accessibility testing by default + * @default true + */ + enableA11yTesting?: boolean; + + /** + * Default direction for components + * @default 'ltr' + */ + defaultDirection?: 'ltr' | 'rtl'; + + /** + * Whether to enable debug mode + * @default false + */ + debug?: boolean; + + /** + * Custom matchers to register globally + */ + customMatchers?: string[]; + + /** + * Performance thresholds for all tests + */ + performanceThresholds?: PerformanceThresholds; +} diff --git a/packages/test/src/utils/accessibility.ts b/packages/test/src/utils/accessibility.ts new file mode 100644 index 00000000..00bbebfd --- /dev/null +++ b/packages/test/src/utils/accessibility.ts @@ -0,0 +1,381 @@ +import { fireEvent, waitFor } from '@testing-library/react'; +import { expect } from 'vitest'; + +import type { A11yTestOptions, HeadlessRenderResult } from '../types'; + +/** + * Comprehensive accessibility testing utilities for headless components + */ +export class AccessibilityTestRunner { + private renderResult: HeadlessRenderResult; + private options: Required; + + constructor(renderResult: HeadlessRenderResult, options: A11yTestOptions = {}) { + this.renderResult = renderResult; + this.options = { + keyboardNavigation: true, + screenReader: true, + focusManagement: true, + colorContrast: false, + customAriaRules: [], + ...options, + }; + } + + /** + * Run all accessibility tests based on configuration + */ + async runAllTests(element: HTMLElement) { + const results: Array<{ test: string; passed: boolean; error?: string }> = []; + + if (this.options.keyboardNavigation) { + try { + await this.testKeyboardNavigation(element); + results.push({ test: 'Keyboard Navigation', passed: true }); + } catch (error) { + results.push({ + test: 'Keyboard Navigation', + passed: false, + error: error instanceof Error ? error.message : String(error) + }); + } + } + + if (this.options.screenReader) { + try { + this.testScreenReaderSupport(element); + results.push({ test: 'Screen Reader Support', passed: true }); + } catch (error) { + results.push({ + test: 'Screen Reader Support', + passed: false, + error: error instanceof Error ? error.message : String(error) + }); + } + } + + if (this.options.focusManagement) { + try { + await this.testFocusManagement(element); + results.push({ test: 'Focus Management', passed: true }); + } catch (error) { + results.push({ + test: 'Focus Management', + passed: false, + error: error instanceof Error ? error.message : String(error) + }); + } + } + + if (this.options.customAriaRules.length > 0) { + try { + this.testCustomAriaRules(element); + results.push({ test: 'Custom ARIA Rules', passed: true }); + } catch (error) { + results.push({ + test: 'Custom ARIA Rules', + passed: false, + error: error instanceof Error ? error.message : String(error) + }); + } + } + + return results; + } + + /** + * Test keyboard navigation capabilities + */ + async testKeyboardNavigation(element: HTMLElement) { + const isInteractive = element.tabIndex >= 0 || + ['button', 'input', 'select', 'textarea', 'a'].includes(element.tagName.toLowerCase()) || + element.getAttribute('role') === 'button'; + + if (!isInteractive) { + return; // Skip keyboard test for non-interactive elements + } + + // Test Tab navigation + fireEvent.focus(element); + expect(element).toHaveFocus(); + + // Test Enter key + fireEvent.keyDown(element, { key: 'Enter' }); + + // Test Space key for button-like elements + if (element.getAttribute('role') === 'button' || element.tagName === 'BUTTON') { + fireEvent.keyDown(element, { key: ' ' }); + } + + // Test Escape key + fireEvent.keyDown(element, { key: 'Escape' }); + } + + /** + * Test screen reader support through ARIA attributes + */ + testScreenReaderSupport(element: HTMLElement) { + const role = element.getAttribute('role'); + const ariaLabel = element.getAttribute('aria-label'); + const ariaLabelledby = element.getAttribute('aria-labelledby'); + const ariaDescribedby = element.getAttribute('aria-describedby'); + + // Check for accessible name + const hasAccessibleName = ariaLabel || + ariaLabelledby || + element.textContent?.trim(); + + if (!hasAccessibleName && element.tagName !== 'DIV') { + throw new Error('Element must have an accessible name for screen readers'); + } + + // Check role consistency + if (role) { + this.validateAriaRole(element, role); + } + + // Check ARIA relationships + if (ariaLabelledby) { + this.validateAriaRelationship(element, 'aria-labelledby', ariaLabelledby); + } + + if (ariaDescribedby) { + this.validateAriaRelationship(element, 'aria-describedby', ariaDescribedby); + } + } + + /** + * Test focus management capabilities + */ + async testFocusManagement(element: HTMLElement) { + // Test initial focus + element.focus(); + await waitFor(() => { + expect(element).toHaveFocus(); + }); + + // Test focus loss + element.blur(); + expect(element).not.toHaveFocus(); + + // Test programmatic focus + fireEvent.focus(element); + expect(element).toHaveFocus(); + } + + /** + * Test custom ARIA rules + */ + testCustomAriaRules(element: HTMLElement) { + this.options.customAriaRules.forEach(rule => { + const [attribute, expectedValue] = rule.split('='); + const actualValue = element.getAttribute(attribute); + + if (expectedValue) { + expect(actualValue).toBe(expectedValue); + } else { + expect(element).toHaveAttribute(attribute); + } + }); + } + + /** + * Validate ARIA role consistency + */ + private validateAriaRole(element: HTMLElement, role: string) { + const validRoles = [ + 'button', 'link', 'textbox', 'checkbox', 'radio', 'slider', 'spinbutton', + 'combobox', 'listbox', 'option', 'menu', 'menuitem', 'menubar', 'tab', 'tablist', 'tabpanel', + 'dialog', 'alertdialog', 'tooltip', 'status', 'alert', 'region', 'group' + ]; + + if (!validRoles.includes(role)) { + console.warn(`Non-standard ARIA role detected: ${role}`); + } + + // Role-specific validation + switch (role) { + case 'button': + // Buttons should be keyboard accessible + if (element.tabIndex < 0) { + throw new Error('Button role elements should be keyboard accessible (tabIndex >= 0)'); + } + break; + + case 'combobox': + // Combobox should have aria-expanded + if (!element.hasAttribute('aria-expanded')) { + throw new Error('Combobox role elements should have aria-expanded attribute'); + } + break; + + case 'slider': + // Slider should have aria-valuenow, aria-valuemin, aria-valuemax + const requiredSliderAttrs = ['aria-valuenow', 'aria-valuemin', 'aria-valuemax']; + requiredSliderAttrs.forEach(attr => { + if (!element.hasAttribute(attr)) { + throw new Error(`Slider role elements should have ${attr} attribute`); + } + }); + break; + } + } + + /** + * Validate ARIA relationship attributes + */ + private validateAriaRelationship(element: HTMLElement, attribute: string, value: string) { + const referencedIds = value.split(' ').filter(id => id.trim()); + + referencedIds.forEach(id => { + const referencedElement = this.renderResult.container.querySelector(`#${id}`); + if (!referencedElement) { + throw new Error(`Referenced element with id "${id}" not found for ${attribute}`); + } + }); + } +} + +/** + * Create accessibility test runner for a rendered component + */ +export function createA11yTestRunner( + renderResult: HeadlessRenderResult, + options?: A11yTestOptions +): AccessibilityTestRunner { + return new AccessibilityTestRunner(renderResult, options); +} + +/** + * Quick accessibility test for an element with default options + */ +export async function testAccessibility( + element: HTMLElement, + renderResult: HeadlessRenderResult, + options?: A11yTestOptions +) { + const runner = createA11yTestRunner(renderResult, options); + const results = await runner.runAllTests(element); + + const failedTests = results.filter(result => !result.passed); + if (failedTests.length > 0) { + const errorMessage = failedTests + .map(test => `${test.test}: ${test.error}`) + .join('\n'); + throw new Error(`Accessibility tests failed:\n${errorMessage}`); + } + + return results; +} + +/** + * Test keyboard navigation patterns common in headless components + */ +export async function testKeyboardPatterns( + element: HTMLElement, + patterns: Array<{ + name: string; + keys: string[]; + expectedBehavior: (element: HTMLElement) => Promise | void; + }> +) { + for (const pattern of patterns) { + fireEvent.focus(element); + + for (const key of pattern.keys) { + fireEvent.keyDown(element, { key }); + } + + await pattern.expectedBehavior(element); + } +} + +/** + * Common keyboard patterns for different component types + */ +export const KEYBOARD_PATTERNS = { + /** + * Standard button keyboard patterns + */ + BUTTON: [ + { + name: 'Enter activation', + keys: ['Enter'], + expectedBehavior: async (element: HTMLElement) => { + // Should trigger click or onChange + expect(element).toHaveAttribute('aria-pressed'); + }, + }, + { + name: 'Space activation', + keys: [' '], + expectedBehavior: async (element: HTMLElement) => { + // Should trigger click or onChange + expect(element).toHaveAttribute('aria-pressed'); + }, + }, + ], + + /** + * Navigation patterns for menus and lists + */ + MENU: [ + { + name: 'Arrow down navigation', + keys: ['ArrowDown'], + expectedBehavior: async (element: HTMLElement) => { + // Should move to next item + const nextItem = element.querySelector('[aria-selected="true"], [data-focus="true"]'); + expect(nextItem).toBeInTheDocument(); + }, + }, + { + name: 'Arrow up navigation', + keys: ['ArrowUp'], + expectedBehavior: async (element: HTMLElement) => { + // Should move to previous item + const prevItem = element.querySelector('[aria-selected="true"], [data-focus="true"]'); + expect(prevItem).toBeInTheDocument(); + }, + }, + { + name: 'Escape to close', + keys: ['Escape'], + expectedBehavior: async (element: HTMLElement) => { + // Menu should close + await waitFor(() => { + expect(element).toHaveAttribute('aria-expanded', 'false'); + }); + }, + }, + ], + + /** + * Tab navigation patterns + */ + TABS: [ + { + name: 'Arrow key navigation', + keys: ['ArrowRight'], + expectedBehavior: async (element: HTMLElement) => { + const selectedTab = element.querySelector('[aria-selected="true"]'); + expect(selectedTab).toBeInTheDocument(); + }, + }, + ], + + /** + * Dialog keyboard patterns + */ + DIALOG: [ + { + name: 'Escape to close', + keys: ['Escape'], + expectedBehavior: async (element: HTMLElement) => { + await waitFor(() => { + expect(element).not.toBeInTheDocument(); + }); + }, + }, + ], +} as const; diff --git a/packages/test/src/utils/compound.ts b/packages/test/src/utils/compound.ts new file mode 100644 index 00000000..1811f427 --- /dev/null +++ b/packages/test/src/utils/compound.ts @@ -0,0 +1,226 @@ +import * as React from 'react'; +import { waitFor } from '@testing-library/react'; +import { expect } from 'vitest'; + +import type { HeadlessRenderResult, CompoundTestConfig } from '../types'; +import { createRenderer } from '../renderer/createRenderer'; +import { createHeadlessTestUtils } from './headless'; + +/** + * Testing utilities specifically designed for compound components + * (components that consist of multiple parts like Dialog.Root, Dialog.Trigger, etc.) + */ +export class CompoundComponentTester { + private config: CompoundTestConfig; + + constructor(config: CompoundTestConfig) { + this.config = config; + } + + /** + * Test that all compound parts are properly connected + */ + async testPartConnections(ui: React.ReactElement): Promise { + const { render } = createRenderer(); + const Provider = this.config.provider; + + const result = Provider + ? render(React.createElement(Provider, { children: ui })) + : render(ui); + + // Check that all required children are present + if (this.config.requiredChildren) { + this.config.requiredChildren.forEach(childName => { + const childElement = result.container.querySelector( + `[data-flippo-component*="${childName}"], [data-testid*="${childName.toLowerCase()}"]` + ); + + expect(childElement, `${childName} component should be rendered`).toBeInTheDocument(); + }); + } + + return result; + } + + /** + * Test that compound component handles context properly + */ + testContextIntegration(ui: React.ReactElement) { + const { render } = createRenderer(); + + // Test with provider + if (this.config.provider) { + expect(() => { + render(React.createElement(this.config.provider!, { children: ui })); + }).not.toThrow(); + } + + // Test without provider (should work or throw meaningful error) + try { + render(ui); + } catch (error) { + // If it throws, it should be a meaningful error about missing context + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('context'); + } + } + + /** + * Test data flow between compound parts + */ + async testDataFlow(config: { + ui: React.ReactElement; + triggerAction: (utils: HeadlessRenderResult) => Promise; + expectedStateChange: (utils: HeadlessRenderResult) => Promise; + }) { + const result = await this.testPartConnections(config.ui); + const utils = createHeadlessTestUtils(result); + + await config.triggerAction(result); + await config.expectedStateChange(result); + } + + /** + * Test that compound component handles errors gracefully + */ + testErrorHandling(scenarios: Array<{ + name: string; + ui: React.ReactElement; + expectedError?: string | RegExp; + }>) { + scenarios.forEach(({ name, ui, expectedError }) => { + if (expectedError) { + expect(() => { + const { render } = createRenderer(); + render(ui); + }, `${name} should throw error`).toThrow(expectedError); + } else { + expect(() => { + const { render } = createRenderer(); + render(ui); + }, `${name} should not throw error`).not.toThrow(); + } + }); + } +} + +/** + * Create compound component tester + */ +export function createCompoundTester(config: CompoundTestConfig): CompoundComponentTester { + return new CompoundComponentTester(config); +} + +/** + * Utility to test common compound component patterns + */ +export function testCompoundPatterns() { + return { + /** + * Test Dialog compound pattern + */ + dialog: createCompoundTester({ + rootComponent: 'Dialog', + requiredChildren: ['DialogTrigger', 'DialogPopup'], + provider: ({ children }) => React.createElement('div', { 'data-dialog-provider': true }, children), + }), + + /** + * Test Menu compound pattern + */ + menu: createCompoundTester({ + rootComponent: 'Menu', + requiredChildren: ['MenuTrigger', 'MenuPopup', 'MenuItem'], + }), + + /** + * Test Tooltip compound pattern + */ + tooltip: createCompoundTester({ + rootComponent: 'Tooltip', + requiredChildren: ['TooltipTrigger', 'TooltipPopup'], + }), + + /** + * Test Accordion compound pattern + */ + accordion: createCompoundTester({ + rootComponent: 'Accordion', + requiredChildren: ['AccordionItem', 'AccordionTrigger', 'AccordionContent'], + }), + + /** + * Test Tabs compound pattern + */ + tabs: createCompoundTester({ + rootComponent: 'Tabs', + requiredChildren: ['TabsList', 'Tab', 'TabsContent'], + }), + }; +} + +/** + * Test that compound component maintains proper ARIA relationships + */ +export async function testAriaRelationships(config: { + renderResult: HeadlessRenderResult; + relationships: Array<{ + source: string; // selector or test id + target: string; // selector or test id + attribute: string; // aria-labelledby, aria-describedby, etc. + }>; +}) { + const { renderResult, relationships } = config; + + for (const relationship of relationships) { + const sourceElement = renderResult.getByTestId(relationship.source) || + renderResult.container.querySelector(relationship.source); + const targetElement = renderResult.getByTestId(relationship.target) || + renderResult.container.querySelector(relationship.target); + + expect(sourceElement, `Source element ${relationship.source} should exist`).toBeInTheDocument(); + expect(targetElement, `Target element ${relationship.target} should exist`).toBeInTheDocument(); + + const ariaValue = sourceElement?.getAttribute(relationship.attribute); + const targetId = targetElement?.getAttribute('id'); + + expect( + ariaValue?.includes(targetId || ''), + `${relationship.attribute} should reference target element ID` + ).toBe(true); + } +} + +/** + * Test compound component state synchronization + */ +export async function testStateSynchronization(config: { + renderResult: HeadlessRenderResult; + stateChanges: Array<{ + action: () => Promise | void; + expectedStates: Array<{ + element: string; // selector or test id + attribute: string; + value: string | boolean; + }>; + }>; +}) { + const { renderResult, stateChanges } = config; + + for (const change of stateChanges) { + await change.action(); + + for (const expectedState of change.expectedStates) { + const element = renderResult.getByTestId(expectedState.element) || + renderResult.container.querySelector(expectedState.element); + + expect(element, `Element ${expectedState.element} should exist`).toBeInTheDocument(); + + if (typeof expectedState.value === 'boolean') { + expect(element).toHaveAttribute(expectedState.attribute, String(expectedState.value)); + } else { + expect(element).toHaveAttribute(expectedState.attribute, expectedState.value); + } + } + } +} diff --git a/packages/test/src/utils/events.ts b/packages/test/src/utils/events.ts new file mode 100644 index 00000000..788e05be --- /dev/null +++ b/packages/test/src/utils/events.ts @@ -0,0 +1,377 @@ +import * as React from 'react'; +import { fireEvent, waitFor } from '@testing-library/react'; +import { vi, expect } from 'vitest'; + +import type { HeadlessRenderResult } from '../types'; + +/** + * Event testing utilities for headless components + */ +export class EventTestRunner { + private renderResult: HeadlessRenderResult; + + constructor(renderResult: HeadlessRenderResult) { + this.renderResult = renderResult; + } + + /** + * Test that HeadlessUIEvent prevents default handler when requested + */ + async testEventPrevention(config: { + element: HTMLElement; + eventType: string; + handler: ReturnType; + preventHandler: boolean; + eventOptions?: any; + }) { + const { element, eventType, handler, preventHandler, eventOptions } = config; + + handler.mockClear(); + + // Create event with preventHeadlessUIHandler capability + const createEvent = (type: string, options: any = {}) => { + const event = new Event(type, { bubbles: true, cancelable: true, ...options }); + + // Add HeadlessUI event properties + Object.defineProperties(event, { + preventHeadlessUIHandler: { + value: () => { + (event as any).headlessUIHandlerPrevented = true; + }, + writable: false, + }, + headlessUIHandlerPrevented: { + value: false, + writable: true, + }, + }); + + return event; + }; + + if (preventHandler) { + // Test that prevention works + const event = createEvent(eventType, eventOptions); + (event as any).preventHeadlessUIHandler(); + + element.dispatchEvent(event); + + expect((event as any).headlessUIHandlerPrevented).toBe(true); + } else { + // Test normal event flow + fireEvent[eventType as keyof typeof fireEvent](element, eventOptions); + expect(handler).toHaveBeenCalled(); + } + } + + /** + * Test event bubbling behavior + */ + async testEventBubbling(config: { + parentElement: HTMLElement; + childElement: HTMLElement; + eventType: string; + shouldBubble: boolean; + parentHandler: ReturnType; + childHandler: ReturnType; + }) { + const { + parentElement, + childElement, + eventType, + shouldBubble, + parentHandler, + childHandler + } = config; + + parentHandler.mockClear(); + childHandler.mockClear(); + + // Add event listeners + parentElement.addEventListener(eventType, parentHandler); + childElement.addEventListener(eventType, childHandler); + + // Trigger event on child + fireEvent[eventType as keyof typeof fireEvent](childElement); + + expect(childHandler).toHaveBeenCalled(); + + if (shouldBubble) { + expect(parentHandler).toHaveBeenCalled(); + } else { + expect(parentHandler).not.toHaveBeenCalled(); + } + } + + /** + * Test custom event handling with proper typing + */ + async testCustomEventHandling(config: { + element: HTMLElement; + customEvents: Array<{ + name: string; + data: any; + handler: ReturnType; + expectedCallCount?: number; + }>; + }) { + const { element, customEvents } = config; + + for (const { name, data, handler, expectedCallCount = 1 } of customEvents) { + handler.mockClear(); + + // Add event listener + element.addEventListener(name, handler); + + // Dispatch custom event + const customEvent = new CustomEvent(name, { detail: data }); + element.dispatchEvent(customEvent); + + expect(handler).toHaveBeenCalledTimes(expectedCallCount); + + if (data) { + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + detail: data, + }) + ); + } + } + } + + /** + * Test event timing and debouncing + */ + async testEventTiming(config: { + element: HTMLElement; + eventType: string; + handler: ReturnType; + rapidFireCount: number; + expectedCallCount: number; + debounceTime?: number; + }) { + const { + element, + eventType, + handler, + rapidFireCount, + expectedCallCount, + debounceTime = 0 + } = config; + + handler.mockClear(); + + // Fire events rapidly + for (let i = 0; i < rapidFireCount; i++) { + fireEvent[eventType as keyof typeof fireEvent](element); + } + + if (debounceTime > 0) { + // Wait for debounce + await waitFor(() => { + expect(handler).toHaveBeenCalledTimes(expectedCallCount); + }, { timeout: debounceTime + 100 }); + } else { + expect(handler).toHaveBeenCalledTimes(expectedCallCount); + } + } + + /** + * Test async event handling + */ + async testAsyncEventHandling(config: { + element: HTMLElement; + eventType: string; + asyncHandler: () => Promise; + timeout?: number; + }) { + const { element, eventType, asyncHandler, timeout = 1000 } = config; + + const handlerSpy = vi.fn().mockImplementation(asyncHandler); + + element.addEventListener(eventType, handlerSpy); + + fireEvent[eventType as keyof typeof fireEvent](element); + + await waitFor(() => { + expect(handlerSpy).toHaveBeenCalled(); + }, { timeout }); + } +} + +/** + * Create event test runner for a rendered component + */ +export function createEventTestRunner(renderResult: HeadlessRenderResult): EventTestRunner { + return new EventTestRunner(renderResult); +} + +/** + * Test common headless UI event patterns + */ +export function testCommonEventPatterns() { + return { + /** + * Test click outside behavior (for dialogs, menus, etc.) + */ + async clickOutside(config: { + renderResult: HeadlessRenderResult; + triggerElement: HTMLElement; + outsideElement?: HTMLElement; + onClickOutside: ReturnType; + }) { + const { renderResult, triggerElement, outsideElement, onClickOutside } = config; + const { user } = renderResult; + + onClickOutside.mockClear(); + + // Click outside + const target = outsideElement || document.body; + await user.click(target); + + expect(onClickOutside).toHaveBeenCalled(); + }, + + /** + * Test escape key behavior + */ + async escapeKey(config: { + element: HTMLElement; + onEscape: ReturnType; + shouldPreventDefault?: boolean; + }) { + const { element, onEscape, shouldPreventDefault = false } = config; + + onEscape.mockClear(); + + const event = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true + }); + + if (shouldPreventDefault) { + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + element.dispatchEvent(event); + expect(preventDefaultSpy).toHaveBeenCalled(); + } else { + element.dispatchEvent(event); + } + + expect(onEscape).toHaveBeenCalled(); + }, + + /** + * Test focus trap behavior (for modals, dialogs) + */ + async focusTrap(config: { + renderResult: HeadlessRenderResult; + containerElement: HTMLElement; + focusableElements: HTMLElement[]; + }) { + const { renderResult, containerElement, focusableElements } = config; + const { user } = renderResult; + + if (focusableElements.length === 0) { + throw new Error('Focus trap test requires at least one focusable element'); + } + + // Focus first element + focusableElements[0].focus(); + expect(focusableElements[0]).toHaveFocus(); + + // Tab through all elements + for (let i = 1; i < focusableElements.length; i++) { + await user.tab(); + expect(focusableElements[i]).toHaveFocus(); + } + + // Tab from last element should cycle to first + await user.tab(); + expect(focusableElements[0]).toHaveFocus(); + + // Shift+Tab should go to last element + await user.tab({ shift: true }); + expect(focusableElements[focusableElements.length - 1]).toHaveFocus(); + }, + + /** + * Test portal event handling + */ + async portalEvents(config: { + renderResult: HeadlessRenderResult; + portalElement: HTMLElement; + sourceElement: HTMLElement; + eventType: string; + handler: ReturnType; + }) { + const { renderResult, portalElement, sourceElement, eventType, handler } = config; + + handler.mockClear(); + + // Events in portal should still be handled by source component + fireEvent[eventType as keyof typeof fireEvent](portalElement); + + await waitFor(() => { + expect(handler).toHaveBeenCalled(); + }); + }, + }; +} + +/** + * Mock HeadlessUI event for testing + */ +export function createMockHeadlessUIEvent( + baseEvent: T, + options: { prevented?: boolean } = {} +): T & { preventHeadlessUIHandler: () => void; headlessUIHandlerPrevented: boolean } { + const { prevented = false } = options; + + return Object.assign(baseEvent, { + preventHeadlessUIHandler: vi.fn(() => { + (baseEvent as any).headlessUIHandlerPrevented = true; + }), + headlessUIHandlerPrevented: prevented, + }); +} + +/** + * Test event handler merging behavior + */ +export function testEventHandlerMerging(config: { + renderResult: HeadlessRenderResult; + element: HTMLElement; + internalHandler: ReturnType; + externalHandler: ReturnType; + eventType: string; + shouldCallBoth?: boolean; + shouldPrevent?: boolean; +}) { + const { + element, + internalHandler, + externalHandler, + eventType, + shouldCallBoth = true, + shouldPrevent = false + } = config; + + internalHandler.mockClear(); + externalHandler.mockClear(); + + const event = createMockHeadlessUIEvent( + new Event(eventType) as any, + { prevented: shouldPrevent } + ); + + element.dispatchEvent(event); + + expect(externalHandler).toHaveBeenCalled(); + + if (shouldCallBoth && !shouldPrevent) { + expect(internalHandler).toHaveBeenCalled(); + } else if (shouldPrevent) { + expect(internalHandler).not.toHaveBeenCalled(); + } +} diff --git a/packages/test/src/utils/headless.ts b/packages/test/src/utils/headless.ts new file mode 100644 index 00000000..41663b54 --- /dev/null +++ b/packages/test/src/utils/headless.ts @@ -0,0 +1,270 @@ +import * as React from 'react'; +import { fireEvent, waitFor, screen } from '@testing-library/react'; +import { expect } from 'vitest'; + +import type { HeadlessRenderResult, CompoundTestConfig, InteractionScenario } from '../types'; +import { createRenderer } from '../renderer/createRenderer'; + +/** + * Test utilities specific to Flippo headless components + */ +export class HeadlessTestUtils { + private renderResult: HeadlessRenderResult; + + constructor(renderResult: HeadlessRenderResult) { + this.renderResult = renderResult; + } + + /** + * Tests keyboard navigation for a component + */ + async testKeyboardNavigation(config: { + trigger: HTMLElement; + expectedTarget?: HTMLElement; + key: string; + expectFocus?: boolean; + }) { + const { trigger, expectedTarget, key, expectFocus = true } = config; + + // Focus the trigger element + fireEvent.focus(trigger); + expect(trigger).toHaveFocus(); + + // Perform keyboard action + fireEvent.keyDown(trigger, { key }); + + if (expectFocus && expectedTarget) { + await waitFor(() => { + expect(expectedTarget).toHaveFocus(); + }); + } + } + + /** + * Tests hover interactions for components like Tooltip + */ + async testHoverInteraction(config: { + trigger: HTMLElement; + expectedContent: string | HTMLElement; + shouldAppear?: boolean; + }) { + const { trigger, expectedContent, shouldAppear = true } = config; + const { user } = this.renderResult; + + // Hover over trigger + await user.hover(trigger); + + if (shouldAppear) { + if (typeof expectedContent === 'string') { + await waitFor(() => { + expect(screen.getByText(expectedContent)).toBeInTheDocument(); + }); + } else { + await waitFor(() => { + expect(expectedContent).toBeInTheDocument(); + }); + } + } + + // Unhover + await user.unhover(trigger); + + if (shouldAppear) { + await waitFor(() => { + if (typeof expectedContent === 'string') { + expect(screen.queryByText(expectedContent)).not.toBeInTheDocument(); + } else { + expect(expectedContent).not.toBeInTheDocument(); + } + }); + } + } + + /** + * Tests focus management for components + */ + async testFocusInteraction(config: { + trigger: HTMLElement; + expectedContent?: string | HTMLElement; + shouldOpen?: boolean; + }) { + const { trigger, expectedContent, shouldOpen = true } = config; + + // Focus trigger + fireEvent.focus(trigger); + + if (shouldOpen && expectedContent) { + await waitFor(() => { + if (typeof expectedContent === 'string') { + expect(screen.getByText(expectedContent)).toBeInTheDocument(); + } else { + expect(expectedContent).toBeInTheDocument(); + } + }); + } + + // Blur trigger + fireEvent.blur(trigger); + + if (shouldOpen && expectedContent) { + await waitFor(() => { + if (typeof expectedContent === 'string') { + expect(screen.queryByText(expectedContent)).not.toBeInTheDocument(); + } else { + expect(expectedContent).not.toBeInTheDocument(); + } + }); + } + } + + /** + * Tests that a component properly handles state changes + */ + async testStateChange(config: { + trigger: HTMLElement; + action: () => Promise | void; + expectedStateChange: () => Promise | void; + }) { + const { trigger, action, expectedStateChange } = config; + + await action(); + await expectedStateChange(); + } + + /** + * Tests accessibility attributes on an element + */ + testAccessibilityAttributes(element: HTMLElement, expectedAttributes: Record) { + Object.entries(expectedAttributes).forEach(([attribute, expectedValue]) => { + if (expectedValue === null) { + expect(element).not.toHaveAttribute(attribute); + } else { + expect(element).toHaveAttribute(attribute, expectedValue); + } + }); + } + + /** + * Tests that event handlers are called with correct arguments + */ + async testEventHandler(config: { + element: HTMLElement; + event: string; + handler: ReturnType; + expectedArgs?: any[]; + eventOptions?: any; + }) { + const { element, event, handler, expectedArgs, eventOptions } = config; + + handler.mockClear(); + + if (event.startsWith('user.')) { + // Handle user event + const userAction = event.replace('user.', ''); + await (this.renderResult.user as any)[userAction](element, eventOptions); + } else { + // Handle fire event + fireEvent[event as keyof typeof fireEvent](element, eventOptions); + } + + expect(handler).toHaveBeenCalled(); + + if (expectedArgs) { + expect(handler).toHaveBeenCalledWith(...expectedArgs); + } + } +} + +/** + * Creates test utilities for headless components + */ +export function createHeadlessTestUtils(renderResult: HeadlessRenderResult): HeadlessTestUtils { + return new HeadlessTestUtils(renderResult); +} + +/** + * Test a compound component structure (e.g., Dialog.Root, Dialog.Trigger, etc.) + */ +export function testCompoundComponent(config: CompoundTestConfig) { + const { rootComponent, requiredChildren = [], provider: Provider } = config; + + return { + /** + * Test that the compound component renders correctly + */ + testBasicRender: (ui: React.ReactElement) => { + const { render } = createRenderer(); + + if (Provider) { + const result = render(React.createElement(Provider, { children: ui })); + return createHeadlessTestUtils(result); + } + + const result = render(ui); + return createHeadlessTestUtils(result); + }, + + /** + * Test that required children are present + */ + testRequiredChildren: (ui: React.ReactElement) => { + const { render } = createRenderer(); + const result = render(ui); + + requiredChildren.forEach(childName => { + expect(result.container.querySelector(`[data-flippo-component="${childName}"]`)) + .toBeInTheDocument(); + }); + + return createHeadlessTestUtils(result); + }, + + /** + * Test error handling when required children are missing + */ + testMissingChildrenError: (uiWithoutChildren: React.ReactElement) => { + const { render } = createRenderer(); + + expect(() => render(uiWithoutChildren)).toThrow(); + }, + }; +} + +/** + * Run a series of interaction scenarios for a component + */ +export async function testInteractionScenarios( + scenarios: InteractionScenario[], + renderResult: HeadlessRenderResult +) { + const utils = createHeadlessTestUtils(renderResult); + + for (const scenario of scenarios) { + await scenario.setup(renderResult); + await scenario.interact(renderResult); + await scenario.assert(renderResult); + } +} + +/** + * Test that a component properly implements the asChild pattern + */ +export function testAsChildPattern(config: { + Component: React.ComponentType; + props?: Record; + asChildElement: React.ReactElement; + expectedTag: string; +}) { + const { Component, props = {}, asChildElement, expectedTag } = config; + const { render } = createRenderer(); + + // Test normal rendering + const normalResult = render(React.createElement(Component, props, 'Content')); + expect(normalResult.container.firstChild?.nodeName.toLowerCase()).not.toBe(expectedTag.toLowerCase()); + + // Test asChild rendering + const asChildResult = render( + React.createElement(Component, { ...props, asChild: true }, asChildElement) + ); + expect(asChildResult.container.firstChild?.nodeName.toLowerCase()).toBe(expectedTag.toLowerCase()); +} diff --git a/packages/test/src/utils/index.ts b/packages/test/src/utils/index.ts new file mode 100644 index 00000000..07290811 --- /dev/null +++ b/packages/test/src/utils/index.ts @@ -0,0 +1,9 @@ +/** + * Utility functions re-exports for convenience + */ + +export * from './headless'; +export * from './accessibility'; +export * from './performance'; +export * from './events'; +export * from './compound'; diff --git a/packages/test/src/utils/performance.ts b/packages/test/src/utils/performance.ts new file mode 100644 index 00000000..89d14627 --- /dev/null +++ b/packages/test/src/utils/performance.ts @@ -0,0 +1,258 @@ +import * as React from 'react'; +import { act } from '@testing-library/react'; +import { expect } from 'vitest'; + +import type { PerformanceThresholds, HeadlessRenderResult } from '../types'; +import { createRenderer } from '../renderer/createRenderer'; + +/** + * Performance testing utilities for headless components + */ +export class PerformanceTestRunner { + private thresholds: Required; + + constructor(thresholds: PerformanceThresholds = {}) { + this.thresholds = { + maxRenderTime: 100, + maxUpdateTime: 50, + maxMemoryUsage: 10, + ...thresholds, + }; + } + + /** + * Measure component render time + */ + async measureRenderTime(renderFn: () => Promise | HeadlessRenderResult): Promise { + const startTime = performance.now(); + + await act(async () => { + await renderFn(); + }); + + const endTime = performance.now(); + const renderTime = endTime - startTime; + + if (renderTime > this.thresholds.maxRenderTime) { + console.warn(`Render time exceeded threshold: ${renderTime}ms > ${this.thresholds.maxRenderTime}ms`); + } + + return renderTime; + } + + /** + * Measure component update time + */ + async measureUpdateTime(updateFn: () => Promise | void): Promise { + const startTime = performance.now(); + + await act(async () => { + await updateFn(); + }); + + const endTime = performance.now(); + const updateTime = endTime - startTime; + + if (updateTime > this.thresholds.maxUpdateTime) { + console.warn(`Update time exceeded threshold: ${updateTime}ms > ${this.thresholds.maxUpdateTime}ms`); + } + + return updateTime; + } + + /** + * Measure memory usage (rough estimation) + */ + measureMemoryUsage(): number { + if ('memory' in performance) { + const memInfo = (performance as any).memory; + const memoryUsageMB = (memInfo.usedJSHeapSize / 1024 / 1024); + + if (memoryUsageMB > this.thresholds.maxMemoryUsage) { + console.warn(`Memory usage exceeded threshold: ${memoryUsageMB}MB > ${this.thresholds.maxMemoryUsage}MB`); + } + + return memoryUsageMB; + } + + return 0; // Memory measurement not available + } + + /** + * Run comprehensive performance test + */ + async runPerformanceTest(config: { + name: string; + renderFn: () => Promise | HeadlessRenderResult; + updateFn?: () => Promise | void; + expectations?: { + renderTime?: number; + updateTime?: number; + memoryUsage?: number; + }; + }): Promise<{ + renderTime: number; + updateTime?: number; + memoryUsage: number; + passed: boolean; + }> { + const { name, renderFn, updateFn, expectations = {} } = config; + + console.log(`Starting performance test: ${name}`); + + // Measure render time + const renderTime = await this.measureRenderTime(renderFn); + + // Measure update time if provided + let updateTime: number | undefined; + if (updateFn) { + updateTime = await this.measureUpdateTime(updateFn); + } + + // Measure memory usage + const memoryUsage = this.measureMemoryUsage(); + + // Check expectations + const renderPassed = !expectations.renderTime || renderTime <= expectations.renderTime; + const updatePassed = !expectations.updateTime || !updateTime || updateTime <= expectations.updateTime; + const memoryPassed = !expectations.memoryUsage || memoryUsage <= expectations.memoryUsage; + + const passed = renderPassed && updatePassed && memoryPassed; + + if (!passed) { + const failures = []; + if (!renderPassed) failures.push(`Render time: ${renderTime}ms > ${expectations.renderTime}ms`); + if (!updatePassed) failures.push(`Update time: ${updateTime}ms > ${expectations.updateTime}ms`); + if (!memoryPassed) failures.push(`Memory usage: ${memoryUsage}MB > ${expectations.memoryUsage}MB`); + + console.error(`Performance test failed for ${name}:\n${failures.join('\n')}`); + } + + return { + renderTime, + updateTime, + memoryUsage, + passed, + }; + } + + /** + * Benchmark component with different prop sets + */ + async benchmarkComponent(config: { + name: string; + Component: React.ComponentType; + propsSets: Array<{ name: string; props: any }>; + iterations?: number; + }): Promise> { + const { name, Component, propsSets, iterations = 10 } = config; + const results: Record = {}; + + for (const propsSet of propsSets) { + const renderTimes: number[] = []; + const memoryUsages: number[] = []; + + for (let i = 0; i < iterations; i++) { + const renderTime = await this.measureRenderTime(() => { + const { render } = createRenderer(); + return render(React.createElement(Component, propsSet.props)); + }); + + const memoryUsage = this.measureMemoryUsage(); + + renderTimes.push(renderTime); + memoryUsages.push(memoryUsage); + } + + // Calculate averages + const avgRenderTime = renderTimes.reduce((sum, time) => sum + time, 0) / iterations; + const avgMemoryUsage = memoryUsages.reduce((sum, usage) => sum + usage, 0) / iterations; + + results[propsSet.name] = { + renderTime: avgRenderTime, + memoryUsage: avgMemoryUsage, + }; + } + + console.log(`Benchmark results for ${name}:`, results); + return results; + } +} + +/** + * Create performance test runner + */ +export function createPerformanceRunner(thresholds?: PerformanceThresholds): PerformanceTestRunner { + return new PerformanceTestRunner(thresholds); +} + +/** + * Quick performance test for a component + */ +export async function testComponentPerformance(config: { + Component: React.ComponentType; + props?: any; + thresholds?: PerformanceThresholds; +}) { + const { Component, props = {}, thresholds } = config; + const runner = createPerformanceRunner(thresholds); + + const result = await runner.runPerformanceTest({ + name: Component.displayName || Component.name || 'Component', + renderFn: () => { + const { render } = createRenderer(); + return render(React.createElement(Component, props)); + }, + }); + + expect(result.passed, + `Component performance test failed. Render time: ${result.renderTime}ms, Memory: ${result.memoryUsage}MB` + ).toBe(true); + + return result; +} + +/** + * Test component performance under stress (many re-renders) + */ +export async function testStressPerformance(config: { + Component: React.ComponentType; + initialProps: any; + propChanges: any[]; + thresholds?: PerformanceThresholds; +}): Promise<{ + totalTime: number; + averageUpdateTime: number; + maxUpdateTime: number; + passed: boolean; +}> { + const { Component, initialProps, propChanges, thresholds } = config; + const runner = createPerformanceRunner(thresholds); + + const { render } = createRenderer(); + const { rerender } = render(React.createElement(Component, initialProps)); + + const updateTimes: number[] = []; + const startTime = performance.now(); + + for (const newProps of propChanges) { + const updateTime = await runner.measureUpdateTime(() => { + rerender(React.createElement(Component, newProps)); + }); + updateTimes.push(updateTime); + } + + const totalTime = performance.now() - startTime; + const averageUpdateTime = updateTimes.reduce((sum, time) => sum + time, 0) / updateTimes.length; + const maxUpdateTime = Math.max(...updateTimes); + + const passed = averageUpdateTime <= (thresholds?.maxUpdateTime || 50) && + maxUpdateTime <= (thresholds?.maxUpdateTime || 50) * 2; + + return { + totalTime, + averageUpdateTime, + maxUpdateTime, + passed, + }; +} diff --git a/packages/test/tsconfig.json b/packages/test/tsconfig.json new file mode 100644 index 00000000..6b6216ed --- /dev/null +++ b/packages/test/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "outDir": "./dist", + "rootDir": "./src", + "moduleResolution": "node", + "allowImportingTsExtensions": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "noEmit": false, + "resolveJsonModule": true, + "types": ["vitest/globals", "@testing-library/jest-dom", "node"] + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "dist", + "node_modules", + "**/*.test.ts", + "**/*.test.tsx" + ] +} diff --git a/packages/test/tsup.config.ts b/packages/test/tsup.config.ts new file mode 100644 index 00000000..e79e996d --- /dev/null +++ b/packages/test/tsup.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['cjs', 'esm'], + dts: true, + clean: true, + splitting: false, + sourcemap: true, + minify: false, + target: 'es2022', + platform: 'neutral', + loader: { + '.tsx': 'tsx', + }, + esbuildOptions(options) { + options.jsx = 'automatic'; + options.jsxImportSource = 'react'; + }, + external: [ + 'react', + 'react-dom', + '@testing-library/react', + '@testing-library/jest-dom', + '@testing-library/user-event', + 'vitest', + 'jsdom' + ], + banner: { + js: `import React from 'react';`, + }, +}); diff --git a/packages/test/vitest.config.ts b/packages/test/vitest.config.ts new file mode 100644 index 00000000..9f02a857 --- /dev/null +++ b/packages/test/vitest.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./src/setup/vitest.ts'], + include: ['src/**/*.{test,spec}.{ts,tsx}'], + exclude: ['node_modules', 'dist', 'build'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/', + 'src/test/', + 'src/**/*.test.{ts,tsx}', + 'src/**/*.spec.{ts,tsx}', + 'src/types/', + ], + }, + testTimeout: 10000, + hookTimeout: 10000, + }, + esbuild: { + target: 'node14', + }, +}); diff --git a/packages/ui/uikit/headless/components/eslint.config.js b/packages/ui/uikit/headless/components/eslint.config.js new file mode 100644 index 00000000..b6f72f18 --- /dev/null +++ b/packages/ui/uikit/headless/components/eslint.config.js @@ -0,0 +1,75 @@ +import { + createEslintConfig, + general, + overridesStylisticConfig, + overridesTsConfig +} from '@flippo/eslint'; + +export default createEslintConfig( + { + pnpm: true, + react: true, + typescript: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname + }, + overrides: { + ...overridesTsConfig, + 'ts/no-namespace': 'off', + 'ts/prefer-literal-enum-member': 'off', + 'ts/no-unsafe-function-type': 'off' + } + }, + stylistic: { + jsx: true, + semi: true, + overrides: overridesStylisticConfig + }, + jsx: true, + formatters: true, + ...general, + ignores: ['**/*.md/*.ts'] + }, + { + rules: { + 'react-dom/no-flush-sync': 'off', + 'react/no-context-provider': 'off', + 'unused-imports/no-unused-vars': ['warn', { + varsIgnorePattern: '^_', + argsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_' + }], + 'node/prefer-global/process': ['error', 'always'], + 'perfectionist/sort-imports': ['error', { + type: 'natural', + order: 'asc', + newlinesBetween: 'always', + internalPattern: ['^~/.+', '^@/.+'], + groups: [ + 'react', + 'builtin', + 'builtin-type', + 'external', + 'external-type', + 'internal', + 'internal-type', + 'parent', + 'parent-type', + 'sibling', + 'sibling-type', + 'unknown', + 'index', + 'index-type', + 'object', + 'type' + ], + tsconfigRootDir: './tsconfig.json', + customGroups: [{ + groupName: 'react', + elementNamePattern: ['^react$', '^react-.+'] + }] + }] + } + } +); diff --git a/packages/ui/uikit/headless/components/package.json b/packages/ui/uikit/headless/components/package.json new file mode 100644 index 00000000..2a7a861b --- /dev/null +++ b/packages/ui/uikit/headless/components/package.json @@ -0,0 +1,112 @@ +{ + "name": "@flippo-ui/headless-components", + "type": "module", + "version": "0.1.0", + "private": false, + "packageManager": "pnpm@10.7.0", + "description": "", + "author": "@BlackPoretsky", + "license": "ISC", + "keywords": [ + "react", + "react-component", + "flippo", + "unstyled", + "a11y" + ], + "exports": { + ".": "./src/index.ts", + "./accordion": "./src/Accordion/index.ts", + "./avatar": "./src/avatar/index.ts", + "./checkbox": "./src/Checkbox/index.ts", + "./checkbox-group": "./src/CheckboxGroup/index.ts", + "./collapsible": "./src/Collapsible/index.ts", + "./context-menu": "./src/ContextMenu/index.ts", + "./dialog": "./src/Dialog/index.ts", + "./direction-provider": "./src/lib/hooks/useDirection.ts", + "./field": "./src/Field/index.ts", + "./fieldset": "./src/Fieldset/index.ts", + "./form": "./src/Form/index.ts", + "./input": "./src/Input/index.ts", + "./menu": "./src/Menu/index.ts", + "./menubar": "./src/Menubar/index.ts", + "./merge-props": "./src/lib/merge.ts", + "./meter": "./src/Meter/index.ts", + "./number-field": "./src/NumberField/index.ts", + "./popover": "./src/Popover/index.ts", + "./progress": "./src/Progress/index.ts", + "./radio": "./src/Radio/index.ts", + "./radio-group": "./src/RadioGroup/index.ts", + "./select": "./src/Select/index.ts", + "./separator": "./src/Separator/index.ts", + "./slider": "./src/Slider/index.ts", + "./switch": "./src/Switch/index.ts", + "./tabs": "./src/Tabs/index.ts", + "./toast": "./src/Toast/index.ts", + "./toggle": "./src/Toggle/index.ts", + "./toggle-group": "./src/ToggleGroup/index.ts", + "./toolbar": "./src/Toolbar/index.ts", + "./tooltip": "./src/Tooltip/index.ts", + "./use-render": "./src/use-render/index.ts", + "./use-button": "./src/use-button/index.ts" + }, + "main": "./src/index.ts", + "module": "./src/index.ts", + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } + } + }, + "files": [ + "README.md", + "dist" + ], + "scripts": { + "test": "vitest", + "test:ui": "vitest --ui" + }, + "peerDependencies": { + "@flippo_ui/hooks": "workspace:*" + }, + "dependencies": { + "@floating-ui/react": "catalog:", + "@floating-ui/react-dom": "catalog:", + "@floating-ui/utils": "catalog:", + "@vitejs/plugin-react": "catalog:", + "tabbable": "catalog:", + "vite": "catalog:" + }, + "devDependencies": { + "@eslint-react/eslint-plugin": "catalog:", + "@flippo/eslint": "workspace:*", + "@flippo/tsconfig": "workspace:*", + "@testing-library/jest-dom": "catalog:conflicts_@testing-library/jest-dom_h6_4_8", + "@testing-library/react": "catalog:conflicts_@testing-library/react_h16_0_0", + "@testing-library/user-event": "catalog:conflicts_@testing-library/user-event_h14_5_2", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitest/ui": "catalog:conflicts_@vitest/ui_h2_0_4", + "eslint": "catalog:", + "eslint-plugin-format": "catalog:", + "eslint-plugin-react-hooks": "catalog:", + "eslint-plugin-react-refresh": "catalog:", + "jsdom": "catalog:conflicts_jsdom_h24_1_1", + "react": "catalog:", + "react-dom": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:conflicts_vitest_h2_0_4" + } +} diff --git a/packages/ui/uikit/headless/components/src/components/Accordion/header/AccordionHeader.tsx b/packages/ui/uikit/headless/components/src/components/Accordion/header/AccordionHeader.tsx new file mode 100644 index 00000000..7110ca6c --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Accordion/header/AccordionHeader.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { useRenderElement } from '@lib/hooks'; + +import type { HeadlessUIComponentProps } from '@lib/types'; + +import { useAccordionItemContext } from '../item/AccordionItemContext'; +import { accordionStyleHookMapping } from '../item/styleHooks'; + +import type { AccordionItem } from '../item/AccordionItem'; + +/** + * A heading that labels the corresponding panel. + * Renders an `

` element. + * + * Documentation: [Base UI Accordion](https://base-ui.com/react/components/accordion) + */ +export function AccordionHeader(componentProps: AccordionHeader.Props) { + const { + /* eslint-disable unused-imports/no-unused-vars */ + render, + className, + /* eslint-enable unused-imports/no-unused-vars */ + ref, + ...elementProps + } = componentProps; + + const { state } = useAccordionItemContext(); + + const element = useRenderElement('h3', componentProps, { + state, + ref, + props: elementProps, + customStyleHookMapping: accordionStyleHookMapping + }); + + return element; +} + +export namespace AccordionHeader { + export type Props = HeadlessUIComponentProps<'h3', AccordionItem.State>; +} diff --git a/packages/ui/uikit/headless/components/src/components/Accordion/header/AccordionHeaderDataAttributes.ts b/packages/ui/uikit/headless/components/src/components/Accordion/header/AccordionHeaderDataAttributes.ts new file mode 100644 index 00000000..3606b080 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Accordion/header/AccordionHeaderDataAttributes.ts @@ -0,0 +1,15 @@ +export enum AccordionHeaderDataAttributes { + /** + * Indicates the index of the accordion item. + * @type {number} + */ + index = 'data-index', + /** + * Present when the accordion item is disabled. + */ + disabled = 'data-disabled', + /** + * Present when the accordion item is open. + */ + open = 'data-open' +} diff --git a/packages/ui/uikit/headless/components/src/components/Accordion/index.parts.ts b/packages/ui/uikit/headless/components/src/components/Accordion/index.parts.ts new file mode 100644 index 00000000..c464c968 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Accordion/index.parts.ts @@ -0,0 +1,5 @@ +export { AccordionHeader as Header } from './header/AccordionHeader'; +export { AccordionItem as Item } from './item/AccordionItem'; +export { AccordionPanel as Panel } from './panel/AccordionPanel'; +export { AccordionRoot as Root } from './root/AccordionRoot'; +export { AccordionTrigger as Trigger } from './trigger/AccordionTrigger'; diff --git a/packages/ui/uikit/headless/components/src/components/Accordion/index.ts b/packages/ui/uikit/headless/components/src/components/Accordion/index.ts new file mode 100644 index 00000000..bc75beb6 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Accordion/index.ts @@ -0,0 +1 @@ +export * as Accordion from './index.parts'; diff --git a/packages/ui/uikit/headless/components/src/components/Accordion/item/AccordionItem.tsx b/packages/ui/uikit/headless/components/src/components/Accordion/item/AccordionItem.tsx new file mode 100644 index 00000000..4927f1b9 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Accordion/item/AccordionItem.tsx @@ -0,0 +1,162 @@ +'use client'; + +import React from 'react'; + +import { useEventCallback, useMergedRef } from '@flippo_ui/hooks'; + +import { EMPTY_OBJECT } from '@lib/constants'; +import { useHeadlessUiId, useRenderElement } from '@lib/hooks'; + +import type { HeadlessUIComponentProps } from '@lib/types'; + +import { CollapsibleRootContext } from '../../Collapsible/root/CollapsibleRootContext'; +import { useCollapsibleRoot } from '../../Collapsible/root/useCollapsibleRoot'; +import { useCompositeListItem } from '../../Composite/list/useCompositeListItem'; +import { useAccordionRootContext } from '../root/AccordionRootContext'; + +import type { CollapsibleRoot } from '../../Collapsible/root/CollapsibleRoot'; +import type { TCollapsibleRootContext } from '../../Collapsible/root/CollapsibleRootContext'; +import type { AccordionRoot } from '../root/AccordionRoot'; + +import { AccordionItemContext } from './AccordionItemContext'; +import { accordionStyleHookMapping } from './styleHooks'; + +import type { TAccordionItemContext } from './AccordionItemContext'; + +/** + * Groups an accordion header with the corresponding panel. + * Renders a `
` element. + * + * Documentation: [Base UI Accordion](https://base-ui.com/react/components/accordion) + */ +export function AccordionItem(componentProps: AccordionItem.Props) { + const { + /* eslint-disable unused-imports/no-unused-vars */ + className, + render, + /* eslint-enable unused-imports/no-unused-vars */ + disabled: disabledProp = false, + onOpenChange: onOpenChangeProp, + value: valueProp, + ref, + ...elementProps + } = componentProps; + + const { ref: listItemRef, index } = useCompositeListItem(EMPTY_OBJECT); + const mergedRef = useMergedRef(ref, listItemRef); + + const { + disabled: contextDisabled, + handleValueChange, + state: rootState, + value: openValues + } = useAccordionRootContext(); + + const value = valueProp ?? index; + + const disabled = disabledProp || contextDisabled; + + const isOpen = React.useMemo(() => { + if (!openValues) { + return false; + } + + for (let i = 0; i < openValues.length; i += 1) { + if (openValues[i] === value) { + return true; + } + } + + return false; + }, [openValues, value]); + + const onOpenChange = useEventCallback((nextOpen: boolean) => { + handleValueChange(value, nextOpen); + onOpenChangeProp?.(nextOpen); + }); + + const collapsible = useCollapsibleRoot({ + open: isOpen, + onOpenChange, + disabled + }); + + const collapsibleState: CollapsibleRoot.State = React.useMemo( + () => ({ + open: collapsible.open, + disabled: collapsible.disabled, + hidden: !collapsible.mounted + }), + [collapsible.open, collapsible.disabled, collapsible.mounted] + ); + + const collapsibleContext: TCollapsibleRootContext = React.useMemo( + () => ({ + ...collapsible, + onOpenChange, + state: collapsibleState, + transitionStatus: collapsible.transitionStatus + }), + [collapsible, collapsibleState, onOpenChange] + ); + + const state: AccordionItem.State = React.useMemo( + () => ({ + ...rootState, + index, + disabled, + open: isOpen + }), + [ + disabled, + index, + isOpen, + rootState + ] + ); + + const [triggerId, setTriggerId] = React.useState(useHeadlessUiId()); + + const accordionItemContext: TAccordionItemContext = React.useMemo( + () => ({ + open: isOpen, + state, + setTriggerId, + triggerId + }), + [ + isOpen, + state, + setTriggerId, + triggerId + ] + ); + + const element = useRenderElement('div', componentProps, { + state, + ref: mergedRef, + props: elementProps, + customStyleHookMapping: accordionStyleHookMapping + }); + + return ( + + + {element} + + + ); +} + +export type AccordionItemValue = any | null; + +export namespace AccordionItem { + export type State = { + index: number; + open: boolean; + } & AccordionRoot.State; + + export type Props = { + value?: AccordionItemValue; + } & HeadlessUIComponentProps<'div', State> & Partial>; +} diff --git a/packages/ui/uikit/headless/components/src/components/Accordion/item/AccordionItemContext.ts b/packages/ui/uikit/headless/components/src/components/Accordion/item/AccordionItemContext.ts new file mode 100644 index 00000000..a50cac90 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Accordion/item/AccordionItemContext.ts @@ -0,0 +1,27 @@ +'use client'; + +import React from 'react'; + +import type { AccordionItem } from './AccordionItem'; + +export type TAccordionItemContext = { + open: boolean; + state: AccordionItem.State; + setTriggerId: (id: string | undefined) => void; + triggerId?: string; +}; + +export const AccordionItemContext = React.createContext( + undefined +); + +export function useAccordionItemContext() { + const context = React.use(AccordionItemContext); + + if (context === undefined) { + throw new Error( + 'Headless UI: AccordionItemContext is missing. Accordion parts must be placed within .' + ); + } + return context; +} diff --git a/packages/ui/uikit/headless/components/src/components/Accordion/item/AccordionItemDataAttributes.ts b/packages/ui/uikit/headless/components/src/components/Accordion/item/AccordionItemDataAttributes.ts new file mode 100644 index 00000000..7362230b --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Accordion/item/AccordionItemDataAttributes.ts @@ -0,0 +1,15 @@ +export enum AccordionItemDataAttributes { + /** + * Indicates the index of the accordion item. + * @type {number} + */ + index = 'data-index', + /** + * Present when the accordion item is disabled. + */ + disabled = 'data-disabled', + /** + * Present when the accordion item is open. + */ + open = 'data-open' +} diff --git a/packages/ui/uikit/headless/components/src/components/Accordion/item/styleHooks.ts b/packages/ui/uikit/headless/components/src/components/Accordion/item/styleHooks.ts new file mode 100644 index 00000000..423cbd76 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Accordion/item/styleHooks.ts @@ -0,0 +1,17 @@ +import { collapsibleOpenStateMapping } from '@lib/collapsibleOpenStateMapping'; +import { transitionStatusMapping } from '@lib/styleHookMapping'; + +import type { CustomStyleHookMapping } from '@lib/getStyleHookProps'; + +import { AccordionItemDataAttributes } from './AccordionItemDataAttributes'; + +import type { AccordionItem } from './AccordionItem'; + +export const accordionStyleHookMapping: CustomStyleHookMapping = { + ...collapsibleOpenStateMapping, + index: (value) => { + return Number.isInteger(value) ? { [AccordionItemDataAttributes.index]: String(value) } : null; + }, + ...transitionStatusMapping, + value: () => null +}; diff --git a/packages/ui/uikit/headless/components/src/components/Accordion/panel/AccordionPanel.tsx b/packages/ui/uikit/headless/components/src/components/Accordion/panel/AccordionPanel.tsx new file mode 100644 index 00000000..757760d0 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Accordion/panel/AccordionPanel.tsx @@ -0,0 +1,176 @@ +'use client'; + +import React from 'react'; + +import { useIsoLayoutEffect, useOpenChangeComplete } from '@flippo_ui/hooks'; + +import type { TransitionStatus } from '@flippo_ui/hooks'; + +import { useRenderElement } from '@lib/hooks'; +import { warn } from '@lib/warn'; + +import type { HeadlessUIComponentProps } from '@lib/types'; + +import { useCollapsiblePanel } from '../../Collapsible/panel/useCollapsiblePanel'; +import { useCollapsibleRootContext } from '../../Collapsible/root/CollapsibleRootContext'; +import { useAccordionItemContext } from '../item/AccordionItemContext'; +import { accordionStyleHookMapping } from '../item/styleHooks'; +import { useAccordionRootContext } from '../root/AccordionRootContext'; + +import type { AccordionItem } from '../item/AccordionItem'; +import type { AccordionRoot } from '../root/AccordionRoot'; + +import { AccordionPanelCssVars } from './AccordionPanelCssVars'; + +/** + * A collapsible panel with the accordion item contents. + * Renders a `
` element. + * + * Documentation: [Base UI Accordion](https://base-ui.com/react/components/accordion) + */ +export function AccordionPanel(componentProps: AccordionPanel.Props) { + const { + /* eslint-disable unused-imports/no-unused-vars */ + className, + render, + /* eslint-enable unused-imports/no-unused-vars */ + hiddenUntilFound: hiddenUntilFoundProp, + keepMounted: keepMountedProp, + id: idProp, + ref, + ...elementProps + } = componentProps; + + const { hiddenUntilFound: contextHiddenUntilFound, keepMounted: contextKeepMounted } + = useAccordionRootContext(); + + const { + abortControllerRef, + animationTypeRef, + height, + mounted, + onOpenChange, + open, + panelId, + panelRef, + runOnceAnimationsFinish, + setDimensions, + setHiddenUntilFound, + setKeepMounted, + setMounted, + setOpen, + setVisible, + transitionDimensionRef, + visible, + width, + setPanelIdState, + transitionStatus + } = useCollapsibleRootContext(); + + const hiddenUntilFound = hiddenUntilFoundProp ?? contextHiddenUntilFound; + const keepMounted = keepMountedProp ?? contextKeepMounted; + + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line react-hooks/rules-of-hooks + useIsoLayoutEffect(() => { + if (keepMountedProp === false && hiddenUntilFound) { + warn( + 'The `keepMounted={false}` prop on a Accordion.Panel will be ignored when using `contextHiddenUntilFound` on the Panel or the Root since it requires the panel to remain mounted when closed.' + ); + } + }, [hiddenUntilFound, keepMountedProp]); + } + + useIsoLayoutEffect(() => { + if (idProp) { + setPanelIdState(idProp); + return () => { + setPanelIdState(undefined); + }; + } + return undefined; + }, [idProp, setPanelIdState]); + + useIsoLayoutEffect(() => { + setHiddenUntilFound(hiddenUntilFound); + }, [setHiddenUntilFound, hiddenUntilFound]); + + useIsoLayoutEffect(() => { + setKeepMounted(keepMounted); + }, [setKeepMounted, keepMounted]); + + useOpenChangeComplete({ + open: open && transitionStatus === 'idle', + ref: panelRef, + onComplete() { + if (!open) { + return; + } + + setDimensions({ width: undefined, height: undefined }); + } + }); + + const { props } = useCollapsiblePanel({ + abortControllerRef, + animationTypeRef, + externalRef: ref, + height, + hiddenUntilFound, + id: idProp ?? panelId, + keepMounted, + mounted, + onOpenChange, + open, + panelRef, + runOnceAnimationsFinish, + setDimensions, + setMounted, + setOpen, + setVisible, + transitionDimensionRef, + visible, + width + }); + + const { state, triggerId } = useAccordionItemContext(); + + const panelState: AccordionPanel.State = React.useMemo( + () => ({ + ...state, + transitionStatus + }), + [state, transitionStatus] + ); + + const element = useRenderElement('div', componentProps, { + state: panelState, + ref: [ref, panelRef], + props: [props, { + 'aria-labelledby': triggerId, + 'role': 'region', + 'style': { + [AccordionPanelCssVars.accordionPanelHeight as string]: + height === undefined ? 'auto' : `${height}px`, + [AccordionPanelCssVars.accordionPanelWidth as string]: + width === undefined ? 'auto' : `${width}px` + } + }, elementProps], + customStyleHookMapping: accordionStyleHookMapping + }); + + const shouldRender = keepMounted || hiddenUntilFound || (!keepMounted && mounted); + if (!shouldRender) { + return null; + } + + return element; +} + +export namespace AccordionPanel { + export type State = { + transitionStatus: TransitionStatus; + } & AccordionItem.State; + + export type Props = { ref: React.RefObject } & Omit, 'ref'> & Pick; +} diff --git a/packages/ui/uikit/headless/components/src/components/Accordion/panel/AccordionPanelCssVars.ts b/packages/ui/uikit/headless/components/src/components/Accordion/panel/AccordionPanelCssVars.ts new file mode 100644 index 00000000..b81dd27a --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Accordion/panel/AccordionPanelCssVars.ts @@ -0,0 +1,12 @@ +export enum AccordionPanelCssVars { + /** + * The accordion panel's height. + * @type {number} + */ + accordionPanelHeight = '--accordion-panel-height', + /** + * The accordion panel's width. + * @type {number} + */ + accordionPanelWidth = '--accordion-panel-width' +} diff --git a/packages/ui/uikit/headless/components/src/components/Accordion/panel/AccordionPanelDataAttributes.ts b/packages/ui/uikit/headless/components/src/components/Accordion/panel/AccordionPanelDataAttributes.ts new file mode 100644 index 00000000..4cd381cf --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Accordion/panel/AccordionPanelDataAttributes.ts @@ -0,0 +1,29 @@ +import { TransitionStatusDataAttributes } from '@lib/styleHookMapping'; + +export enum AccordionPanelDataAttributes { + /** + * Indicates the index of the accordion item. + * @type {number} + */ + index = 'data-index', + /** + * Present when the accordion panel is open. + */ + open = 'data-open', + /** + * Indicates the orientation of the accordion. + */ + orientation = 'data-orientation', + /** + * Present when the accordion item is disabled. + */ + disabled = 'data-disabled', + /** + * Present when the panel is animating in. + */ + startingStyle = TransitionStatusDataAttributes.startingStyle, + /** + * Present when the panel is animating out. + */ + endingStyle = TransitionStatusDataAttributes.endingStyle +} diff --git a/packages/ui/uikit/headless/components/src/components/Accordion/root/AccordionRoot.tsx b/packages/ui/uikit/headless/components/src/components/Accordion/root/AccordionRoot.tsx new file mode 100644 index 00000000..240801b2 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Accordion/root/AccordionRoot.tsx @@ -0,0 +1,226 @@ +'use client'; + +import React from 'react'; + +import { useControlledState, useEventCallback, useIsoLayoutEffect } from '@flippo_ui/hooks'; + +import { useDirection, useRenderElement } from '@lib/hooks'; +import { warn } from '@lib/warn'; + +import type { HeadlessUIComponentProps, Orientation } from '@lib/types'; + +import { CompositeList } from '../../Composite/list/CompositeList'; + +import { AccordionRootContext } from './AccordionRootContext'; + +import type { TAccordionRootContext } from './AccordionRootContext'; + +const rootStyleHookMapping = { + value: () => null +}; + +/** + * Groups all parts of the accordion. + * Renders a `
` element. + * + * Documentation: [Base UI Accordion](https://base-ui.com/react/components/accordion) + */ +export function AccordionRoot(componentProps: AccordionRoot.Props) { + const { + /* eslint-disable unused-imports/no-unused-vars */ + render, + className, + /* eslint-enable unused-imports/no-unused-vars */ + disabled = false, + hiddenUntilFound: hiddenUntilFoundProp, + keepMounted: keepMountedProp, + loop = true, + onValueChange: onValueChangeProp, + openMultiple = true, + orientation = 'vertical', + value: valueProp, + defaultValue: defaultValueProp, + ref, + ...elementProps + } = componentProps; + + const direction = useDirection(); + + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line react-hooks/rules-of-hooks + useIsoLayoutEffect(() => { + if (hiddenUntilFoundProp && keepMountedProp === false) { + warn( + 'The `keepMounted={false}` prop on a Accordion.Root will be ignored when using `hiddenUntilFound` since it requires Panels to remain mounted when closed.' + ); + } + }, [hiddenUntilFoundProp, keepMountedProp]); + } + + // memoized to allow omitting both defaultValue and value + // which would otherwise trigger a warning in useControlled + const defaultValue = React.useMemo(() => { + if (valueProp === undefined) { + return defaultValueProp ?? []; + } + + return []; + }, [valueProp, defaultValueProp]); + + const onValueChange = useEventCallback(onValueChangeProp); + + const accordionItemRefs = React.useRef<(HTMLElement | null)[]>([]); + + const [value, setValue] = useControlledState({ + prop: valueProp, + defaultProp: defaultValue, + caller: 'Accordion' + }); + + const handleValueChange = React.useCallback( + (newValue: number | string, nextOpen: boolean) => { + if (!openMultiple) { + const nextValue = value[0] === newValue ? [] : [newValue]; + setValue(nextValue); + onValueChange(nextValue); + } + else if (nextOpen) { + const nextOpenValues = value.slice(); + nextOpenValues.push(newValue); + setValue(nextOpenValues); + onValueChange(nextOpenValues); + } + else { + const nextOpenValues = value.filter((v) => v !== newValue); + setValue(nextOpenValues); + onValueChange(nextOpenValues); + } + }, + [ + onValueChange, + openMultiple, + setValue, + value + ] + ); + + const state: AccordionRoot.State = React.useMemo( + () => ({ + value, + disabled, + orientation + }), + [value, disabled, orientation] + ); + + const contextValue: TAccordionRootContext = React.useMemo( + () => ({ + accordionItemRefs, + direction, + disabled, + handleValueChange, + hiddenUntilFound: hiddenUntilFoundProp ?? false, + keepMounted: keepMountedProp ?? false, + loop, + orientation, + state, + value + }), + [ + direction, + disabled, + handleValueChange, + hiddenUntilFoundProp, + keepMountedProp, + loop, + orientation, + state, + value + ] + ); + + const element = useRenderElement('div', componentProps, { + state, + ref, + props: [{ + dir: direction, + role: 'region' + }, elementProps], + customStyleHookMapping: rootStyleHookMapping + }); + + return ( + + {element} + + ); +} + +export type AccordionValue = (any | null)[]; + +export namespace AccordionRoot { + export type State = { + value: AccordionValue; + /** + * Whether the component should ignore user interaction. + */ + disabled: boolean; + orientation: Orientation; + }; + + export type Props = { + /** + * The controlled value of the item(s) that should be expanded. + * + * To render an uncontrolled accordion, use the `defaultValue` prop instead. + */ + value?: AccordionValue; + /** + * The uncontrolled value of the item(s) that should be initially expanded. + * + * To render a controlled accordion, use the `value` prop instead. + */ + defaultValue?: AccordionValue; + /** + * Whether the component should ignore user interaction. + * @default false + */ + disabled?: boolean; + /** + * Allows the browser’s built-in page search to find and expand the panel contents. + * + * Overrides the `keepMounted` prop and uses `hidden="until-found"` + * to hide the element without removing it from the DOM. + * @default false + */ + hiddenUntilFound?: boolean; + /** + * Whether to keep the element in the DOM while the panel is closed. + * This prop is ignored when `hiddenUntilFound` is used. + * @default false + */ + keepMounted?: boolean; + /** + * Whether to loop keyboard focus back to the first item + * when the end of the list is reached while using the arrow keys. + * @default true + */ + loop?: boolean; + /** + * Event handler called when an accordion item is expanded or collapsed. + * Provides the new value as an argument. + */ + onValueChange?: (value: AccordionValue) => void; + /** + * Whether multiple items can be open at the same time. + * @default true + */ + openMultiple?: boolean; + /** + * The visual orientation of the accordion. + * Controls whether roving focus uses left/right or up/down arrow keys. + * @default 'vertical' + */ + orientation?: Orientation; + } & HeadlessUIComponentProps<'div', State>; +} diff --git a/packages/ui/uikit/headless/components/src/components/Accordion/root/AccordionRootContext.ts b/packages/ui/uikit/headless/components/src/components/Accordion/root/AccordionRootContext.ts new file mode 100644 index 00000000..dd0af0b2 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Accordion/root/AccordionRootContext.ts @@ -0,0 +1,35 @@ +'use client'; +import * as React from 'react'; + +import type { TTextDirection } from '@lib/hooks'; +import type { Orientation } from '@lib/types'; + +import type { AccordionRoot, AccordionValue } from './AccordionRoot'; + +export type TAccordionRootContext = { + accordionItemRefs: React.RefObject<(HTMLElement | null)[]>; + direction: TTextDirection; + disabled: boolean; + handleValueChange: (newValue: number | string, nextOpen: boolean) => void; + hiddenUntilFound: boolean; + keepMounted: boolean; + loop: boolean; + orientation: Orientation; + state: AccordionRoot.State; + value: AccordionValue; +}; + +export const AccordionRootContext = React.createContext( + undefined +); + +export function useAccordionRootContext() { + const context = React.use(AccordionRootContext); + + if (context === undefined) { + throw new Error( + 'Headless UI: AccordionRootContext is missing. Accordion parts must be placed within .' + ); + } + return context; +} diff --git a/packages/ui/uikit/headless/components/src/components/Accordion/root/AccordionRootDataAttributes.ts b/packages/ui/uikit/headless/components/src/components/Accordion/root/AccordionRootDataAttributes.ts new file mode 100644 index 00000000..34abbca4 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Accordion/root/AccordionRootDataAttributes.ts @@ -0,0 +1,10 @@ +export enum AccordionRootDataAttributes { + /** + * Present when the accordion is disabled. + */ + disabled = 'data-disabled', + /** + * Indicates the orientation of the accordion. + */ + orientation = 'data-orientation' +} diff --git a/packages/ui/uikit/headless/components/src/components/Accordion/trigger/AccordionTrigger.tsx b/packages/ui/uikit/headless/components/src/components/Accordion/trigger/AccordionTrigger.tsx new file mode 100644 index 00000000..369d7a38 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Accordion/trigger/AccordionTrigger.tsx @@ -0,0 +1,226 @@ +'use client'; + +import React from 'react'; + +import { useIsoLayoutEffect } from '@flippo_ui/hooks'; + +import { triggerOpenStateMapping } from '@lib/collapsibleOpenStateMapping'; +import { useRenderElement } from '@lib/hooks'; +import { isElementDisabled } from '@lib/isElementDisabled'; + +import type { HeadlessUIComponentProps, NativeButtonProps } from '@lib/types'; + +import { useCollapsibleRootContext } from '../../Collapsible/root/CollapsibleRootContext'; +import { + ARROW_DOWN, + ARROW_LEFT, + ARROW_RIGHT, + ARROW_UP, + END, + HOME, + stopEvent +} from '../../Composite/composite'; +import { useButton } from '../../use-button'; +import { useAccordionItemContext } from '../item/AccordionItemContext'; +import { useAccordionRootContext } from '../root/AccordionRootContext'; + +import type { AccordionItem } from '../item/AccordionItem'; + +const SUPPORTED_KEYS = new Set([ + ARROW_DOWN, + ARROW_UP, + ARROW_RIGHT, + ARROW_LEFT, + HOME, + END +]); + +function getActiveTriggers(accordionItemRefs: { + current: (HTMLElement | null)[]; +}): HTMLButtonElement[] { + const { current: accordionItemElements } = accordionItemRefs; + + const output: HTMLButtonElement[] = []; + + for (let i = 0; i < accordionItemElements.length; i += 1) { + const section = accordionItemElements[i]; + if (!isElementDisabled(section)) { + const trigger = section?.querySelector('[type="button"]') as HTMLButtonElement; + if (!isElementDisabled(trigger)) { + output.push(trigger); + } + } + } + + return output; +} + +/** + * A button that opens and closes the corresponding panel. + * Renders a `