diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
new file mode 100644
index 0000000000..1e41f73cb7
--- /dev/null
+++ b/.github/workflows/playwright.yml
@@ -0,0 +1,28 @@
+name: Playwright Tests
+on: [push, pull_request]
+jobs:
+ test:
+ timeout-minutes: 60
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Install dependencies
+ run: bun install
+
+ - name: Install Playwright Browsers
+ run: bun x playwright install --with-deps
+
+ - name: Run Playwright tests
+ run: bun tests:playwright test:pw
+
+ - uses: actions/upload-artifact@v4
+ if: ${{ !cancelled() }}
+ with:
+ name: playwright-report
+ path: playwright-report/
+ retention-days: 30
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 36cfc663c8..6b1cd16361 100644
--- a/.gitignore
+++ b/.gitignore
@@ -311,4 +311,7 @@ vite.config.mts.timestamp-*
*.prompt.md
# Solid Start
-.vinxi
\ No newline at end of file
+.vinxi
+
+# Storybook
+*storybook.log
diff --git a/bun.lockb b/bun.lockb
new file mode 100755
index 0000000000..ebe727d7f3
Binary files /dev/null and b/bun.lockb differ
diff --git a/package.json b/package.json
index 1742fb8710..4b42362a37 100644
--- a/package.json
+++ b/package.json
@@ -5,6 +5,7 @@
"workspaces": [
"packages/*",
"templates/**",
+ "tests/*",
"scripts",
"website"
],
@@ -18,6 +19,7 @@
"format": "bun prettier --write .",
"lint": "turbo run lint",
"test": "turbo run test:ci --concurrency=1",
+ "tests:playwright": "bun run --cwd tests/playwright",
"typecheck": "turbo run typecheck",
"react": "bun run --cwd packages/react",
"scripts": "bun run --cwd scripts",
diff --git a/packages/react/src/components/avatar/examples/basic.tsx b/packages/react/src/components/avatar/examples/basic.tsx
index 96b4fbffc0..915de46c38 100644
--- a/packages/react/src/components/avatar/examples/basic.tsx
+++ b/packages/react/src/components/avatar/examples/basic.tsx
@@ -3,6 +3,6 @@ import { Avatar } from '@ark-ui/react/avatar'
export const Basic = () => (
PA
-
+
)
diff --git a/packages/react/src/components/avatar/examples/events.tsx b/packages/react/src/components/avatar/examples/events.tsx
index 520b34dede..0fd655590d 100644
--- a/packages/react/src/components/avatar/examples/events.tsx
+++ b/packages/react/src/components/avatar/examples/events.tsx
@@ -7,7 +7,7 @@ export const Events = () => {
return (
PA
-
+
)
}
diff --git a/packages/react/src/components/avatar/examples/root-provider.tsx b/packages/react/src/components/avatar/examples/root-provider.tsx
index 60d48637e0..72556a7155 100644
--- a/packages/react/src/components/avatar/examples/root-provider.tsx
+++ b/packages/react/src/components/avatar/examples/root-provider.tsx
@@ -11,7 +11,7 @@ export const RootProvider = () => {
PA
-
+
>
)
diff --git a/packages/solid/src/components/avatar/examples/basic.tsx b/packages/solid/src/components/avatar/examples/basic.tsx
index c35ba5ce23..5c23771155 100644
--- a/packages/solid/src/components/avatar/examples/basic.tsx
+++ b/packages/solid/src/components/avatar/examples/basic.tsx
@@ -3,6 +3,6 @@ import { Avatar } from '@ark-ui/solid/avatar'
export const Basic = () => (
PA
-
+
)
diff --git a/packages/svelte/src/lib/components/avatar/examples/basic.svelte b/packages/svelte/src/lib/components/avatar/examples/basic.svelte
index fe7ce4684b..f0130720bd 100644
--- a/packages/svelte/src/lib/components/avatar/examples/basic.svelte
+++ b/packages/svelte/src/lib/components/avatar/examples/basic.svelte
@@ -4,5 +4,5 @@
PA
-
+
diff --git a/packages/vue/.storybook/main.ts b/packages/vue/.storybook/main.ts
index 42ef82044a..5a1144d740 100644
--- a/packages/vue/.storybook/main.ts
+++ b/packages/vue/.storybook/main.ts
@@ -1 +1,24 @@
-import './main.css'
+import type { StorybookConfig } from '@storybook/vue3-vite'
+
+const config: StorybookConfig = {
+ stories: ['../src/**/*.stories.ts'],
+ addons: [
+ {
+ name: '@storybook/addon-essentials',
+ options: { backgrounds: false, controls: false, actions: false },
+ },
+ '@storybook/addon-a11y',
+ ],
+ framework: {
+ name: '@storybook/vue3-vite',
+ options: {},
+ },
+ core: {
+ disableTelemetry: true,
+ },
+ docs: {
+ autodocs: false,
+ },
+}
+
+export default config
diff --git a/packages/vue/.storybook/preview.ts b/packages/vue/.storybook/preview.ts
new file mode 100644
index 0000000000..a81e8b569b
--- /dev/null
+++ b/packages/vue/.storybook/preview.ts
@@ -0,0 +1,16 @@
+import type { Preview } from '@storybook/vue3'
+import './main.css'
+
+const preview: Preview = {
+ parameters: {
+ options: {
+ storySort: {
+ method: 'alphabetical',
+ },
+ },
+ layout: 'padded',
+ },
+ tags: ['autodocs'],
+}
+
+export default preview
diff --git a/packages/vue/package.json b/packages/vue/package.json
index e8e9fedee7..9304169299 100644
--- a/packages/vue/package.json
+++ b/packages/vue/package.json
@@ -72,7 +72,8 @@
"test": "vitest",
"test:ci": "vitest --run",
"typecheck": "vue-tsc",
- "storybook": "histoire dev",
+ "histoire": "histoire dev",
+ "storybook": "storybook dev -p 6009",
"release-it": "release-it --config ../../release-it.json",
"prepack": "clean-package",
"postpack": "clean-package restore"
@@ -137,8 +138,16 @@
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
+ "@chromatic-com/storybook": "^3.2.5",
"@histoire/plugin-vue": "0.17.17",
"@release-it/keep-a-changelog": "6.0.0",
+ "@storybook/addon-essentials": "^8.6.4",
+ "@storybook/addon-onboarding": "^8.6.4",
+ "@storybook/blocks": "^8.6.4",
+ "@storybook/experimental-addon-test": "^8.6.4",
+ "@storybook/test": "^8.6.4",
+ "@storybook/vue3": "^8.6.4",
+ "@storybook/vue3-vite": "^8.6.4",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/user-event": "14.6.1",
@@ -146,14 +155,18 @@
"@types/jsdom": "21.1.7",
"@vitejs/plugin-vue": "5.2.1",
"@vitejs/plugin-vue-jsx": "4.1.1",
+ "@vitest/browser": "3.0.8",
+ "@vitest/coverage-v8": "3.0.8",
"@vue/compiler-sfc": "3.5.13",
"clean-package": "2.2.0",
"globby": "14.1.0",
"histoire": "0.17.17",
"jsdom": "26.0.0",
"lucide-vue-next": "0.479.0",
+ "playwright": "1.51.0",
"release-it": "18.1.2",
"resize-observer-polyfill": "1.5.1",
+ "storybook": "^8.6.4",
"typescript": "5.8.2",
"vite": "6.2.0",
"vite-plugin-dts": "4.5.3",
diff --git a/packages/vue/src/components/avatar/avatar.stories.ts b/packages/vue/src/components/avatar/avatar.stories.ts
new file mode 100644
index 0000000000..4f95cdfb2d
--- /dev/null
+++ b/packages/vue/src/components/avatar/avatar.stories.ts
@@ -0,0 +1,32 @@
+import type { Meta } from '@storybook/react'
+
+import BasicExample from './examples/basic.vue'
+import EventsExample from './examples/events.vue'
+import RootProviderExample from './examples/root-provider.vue'
+
+const meta = {
+ title: 'Components / Avatar',
+} as Meta
+
+export default meta
+
+export const Basic = {
+ render: () => ({
+ components: { BasicExample },
+ template: '',
+ }),
+}
+
+export const Events = {
+ render: () => ({
+ components: { EventsExample },
+ template: '',
+ }),
+}
+
+export const RootProvider = {
+ render: () => ({
+ components: { RootProviderExample },
+ template: '',
+ }),
+}
diff --git a/packages/vue/src/components/avatar/examples/basic.vue b/packages/vue/src/components/avatar/examples/basic.vue
index b4245ae3e9..41007ea549 100644
--- a/packages/vue/src/components/avatar/examples/basic.vue
+++ b/packages/vue/src/components/avatar/examples/basic.vue
@@ -5,6 +5,6 @@ import { Avatar } from '@ark-ui/vue/avatar'
PA
-
+
diff --git a/packages/vue/src/components/avatar/examples/events.vue b/packages/vue/src/components/avatar/examples/events.vue
index 93009e3f5c..23f1e6fc67 100644
--- a/packages/vue/src/components/avatar/examples/events.vue
+++ b/packages/vue/src/components/avatar/examples/events.vue
@@ -5,6 +5,6 @@ import { Avatar } from '@ark-ui/vue/avatar'
console.log(e.status)">
PA
-
+
diff --git a/packages/vue/src/components/avatar/examples/root-provider.vue b/packages/vue/src/components/avatar/examples/root-provider.vue
index 694f4a9748..d10a5405c5 100644
--- a/packages/vue/src/components/avatar/examples/root-provider.vue
+++ b/packages/vue/src/components/avatar/examples/root-provider.vue
@@ -5,10 +5,10 @@ const avatar = useAvatar()
-
+
PA
-
+
diff --git a/tests/playwright/.gitignore b/tests/playwright/.gitignore
new file mode 100644
index 0000000000..58786aac75
--- /dev/null
+++ b/tests/playwright/.gitignore
@@ -0,0 +1,7 @@
+
+# Playwright
+node_modules/
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
diff --git a/tests/playwright/package.json b/tests/playwright/package.json
new file mode 100644
index 0000000000..53c80b6ba4
--- /dev/null
+++ b/tests/playwright/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "playwright",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "scripts": {
+ "test:pw": "playwright test",
+ "test:pw:ui": "playwright test --ui",
+ "test:pw:update": "playwright test --trace on --update-snapshots",
+ "react:storybook": "bun run --cwd ../../packages/react storybook",
+ "vue:storybook": "bun run --cwd ../../packages/vue storybook",
+ "solid:storybook": "bun run --cwd ../../packages/solid storybook"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "devDependencies": {
+ "@axe-core/playwright": "4.10.1",
+ "@playwright/test": "^1.51.0",
+ "@types/node": "^22.13.10"
+ }
+}
diff --git a/tests/playwright/playwright.config.ts b/tests/playwright/playwright.config.ts
new file mode 100644
index 0000000000..412672ef9e
--- /dev/null
+++ b/tests/playwright/playwright.config.ts
@@ -0,0 +1,98 @@
+import { defineConfig, devices } from '@playwright/test'
+
+/**
+ * Read environment variables from file.
+ * https://github.com/motdotla/dotenv
+ */
+// import dotenv from 'dotenv';
+// import path from 'path';
+// dotenv.config({ path: path.resolve(__dirname, '.env') });
+
+/**
+ * See https://playwright.dev/docs/test-configuration.
+ */
+export default defineConfig({
+ testDir: './tests/',
+
+ snapshotPathTemplate: '{testDir}/{testFileDir}/__screenshots__/{projectName}/{arg}{ext}',
+ /* Run tests in files in parallel */
+ fullyParallel: true,
+ /* Fail the build on CI if you accidentally left test.only in the source code. */
+ forbidOnly: !!process.env.CI,
+ /* Retry on CI only */
+ retries: process.env.CI ? 2 : 0,
+ /* Opt out of parallel tests on CI. */
+ workers: process.env.CI ? 1 : undefined,
+ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
+ reporter: 'html',
+ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
+ use: {
+ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
+ trace: 'on-first-retry',
+ },
+
+ expect: {
+ toHaveScreenshot: {
+ maxDiffPixelRatio: 0.02,
+ },
+ },
+
+ /* Configure projects for major browsers */
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+
+ // {
+ // name: 'firefox',
+ // use: { ...devices['Desktop Firefox'] },
+ // },
+
+ // {
+ // name: 'webkit',
+ // use: { ...devices['Desktop Safari'] },
+ // },
+
+ /* Test against mobile viewports. */
+ // {
+ // name: 'Mobile Chrome',
+ // use: { ...devices['Pixel 5'] },
+ // },
+ // {
+ // name: 'Mobile Safari',
+ // use: { ...devices['iPhone 12'] },
+ // },
+
+ /* Test against branded browsers. */
+ // {
+ // name: 'Microsoft Edge',
+ // use: { ...devices['Desktop Edge'], channel: 'msedge' },
+ // },
+ // {
+ // name: 'Google Chrome',
+ // use: { ...devices['Desktop Chrome'], channel: 'chrome' },
+ // },
+ ],
+
+ /* Run your local dev server before starting the tests */
+ // webServer: {
+ // command: 'npm run start',
+ // url: 'http://127.0.0.1:3000',
+ // reuseExistingServer: !process.env.CI,
+ // },
+ webServer: [
+ {
+ command: 'bun react:storybook',
+ url: 'http://localhost:6006',
+ timeout: 120 * 1000,
+ reuseExistingServer: !process.env.CI,
+ },
+ {
+ command: 'bun vue:storybook',
+ url: 'http://localhost:6009',
+ timeout: 120 * 1000,
+ reuseExistingServer: !process.env.CI,
+ },
+ ],
+})
diff --git a/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/react-basic-variant-has-avatar-image-1.png b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/react-basic-variant-has-avatar-image-1.png
new file mode 100644
index 0000000000..482263c1a5
Binary files /dev/null and b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/react-basic-variant-has-avatar-image-1.png differ
diff --git a/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/react-events-variant-has-avatar-image-1.png b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/react-events-variant-has-avatar-image-1.png
new file mode 100644
index 0000000000..482263c1a5
Binary files /dev/null and b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/react-events-variant-has-avatar-image-1.png differ
diff --git a/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/react-root-provider-variant-changes-avatar-source-when-out-of-component-button-is-clicked-1.png b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/react-root-provider-variant-changes-avatar-source-when-out-of-component-button-is-clicked-1.png
new file mode 100644
index 0000000000..95d130ddef
Binary files /dev/null and b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/react-root-provider-variant-changes-avatar-source-when-out-of-component-button-is-clicked-1.png differ
diff --git a/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/react-root-provider-variant-has-avatar-image-1.png b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/react-root-provider-variant-has-avatar-image-1.png
new file mode 100644
index 0000000000..7632d7a4d0
Binary files /dev/null and b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/react-root-provider-variant-has-avatar-image-1.png differ
diff --git a/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/vue-basic-variant-has-avatar-image-1.png b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/vue-basic-variant-has-avatar-image-1.png
new file mode 100644
index 0000000000..482263c1a5
Binary files /dev/null and b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/vue-basic-variant-has-avatar-image-1.png differ
diff --git a/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/vue-events-variant-has-avatar-image-1.png b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/vue-events-variant-has-avatar-image-1.png
new file mode 100644
index 0000000000..482263c1a5
Binary files /dev/null and b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/vue-events-variant-has-avatar-image-1.png differ
diff --git a/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/vue-root-provider-variant-changes-avatar-source-when-out-of-component-button-is-clicked-1.png b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/vue-root-provider-variant-changes-avatar-source-when-out-of-component-button-is-clicked-1.png
new file mode 100644
index 0000000000..95d130ddef
Binary files /dev/null and b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/vue-root-provider-variant-changes-avatar-source-when-out-of-component-button-is-clicked-1.png differ
diff --git a/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/vue-root-provider-variant-has-avatar-image-1.png b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/vue-root-provider-variant-has-avatar-image-1.png
new file mode 100644
index 0000000000..7632d7a4d0
Binary files /dev/null and b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/vue-root-provider-variant-has-avatar-image-1.png differ
diff --git a/tests/playwright/tests/packages/components/avatar/avatar.pw.test.ts b/tests/playwright/tests/packages/components/avatar/avatar.pw.test.ts
new file mode 100644
index 0000000000..7a089b2fa4
--- /dev/null
+++ b/tests/playwright/tests/packages/components/avatar/avatar.pw.test.ts
@@ -0,0 +1,86 @@
+import { expect, test } from '@playwright/test'
+import { type PackageName, gotoStory, testA11yWithAttachedResults } from '../../components/utils'
+
+const packages: PackageName[] = ['react', 'vue']
+
+for (const packageName of packages) {
+ test.describe(`${packageName}: basic variant`, () => {
+ test.beforeEach(async ({ page }) => {
+ await gotoStory('avatar', 'basic', page, packageName)
+ await page.getByAltText('avatar').waitFor()
+ })
+ test('has avatar image', async ({ page }) => {
+ await expect(page.getByAltText('avatar')).toHaveAttribute(
+ 'src',
+ 'https://i.pravatar.cc/300?u=a042581f4e29026704d',
+ )
+ await expect(page.locator('#storybook-root')).toHaveScreenshot()
+ })
+ test('has no a11y violations', async ({ page }, testInfo) => {
+ const accessibilityScanResults = await testA11yWithAttachedResults(page, testInfo, 'avatar')
+ await expect(accessibilityScanResults.violations).toEqual([])
+ })
+ })
+
+ test.describe(`${packageName}: events variant`, () => {
+ test.beforeEach(async ({ page }) => {
+ await gotoStory('avatar', 'events', page, packageName)
+ await page.getByAltText('avatar').waitFor()
+ })
+ test('has avatar image', async ({ page }) => {
+ await expect(page.getByAltText('avatar')).toHaveAttribute(
+ 'src',
+ 'https://i.pravatar.cc/300?u=a042581f4e29026704d',
+ )
+ await expect(page.locator('#storybook-root')).toHaveScreenshot()
+ })
+ test('has no a11y violations', async ({ page }, testInfo) => {
+ const accessibilityScanResults = await testA11yWithAttachedResults(page, testInfo, 'avatar')
+ await expect(accessibilityScanResults.violations).toEqual([])
+ })
+ test('emits status-change event and logs to console', async ({ page }) => {
+ const consoleMessages: string[] = []
+ page.on('console', (msg) => {
+ if (msg.type() === 'log') {
+ consoleMessages.push(msg.text())
+ }
+ })
+
+ await gotoStory('avatar', 'events', page, packageName)
+ await page.getByAltText('avatar').waitFor()
+
+ const hasStatusChangeEvent = consoleMessages.some((msg) => msg.includes('loaded'))
+ expect(hasStatusChangeEvent).toBeTruthy()
+ })
+ })
+
+ test.describe(`${packageName}: root-provider variant`, () => {
+ test.beforeEach(async ({ page }) => {
+ await gotoStory('avatar', 'root-provider', page, packageName)
+ await page.getByAltText('avatar').waitFor()
+ })
+ test('has avatar image', async ({ page }) => {
+ await expect(page.getByAltText('avatar')).toHaveAttribute(
+ 'src',
+ 'https://i.pravatar.cc/300?u=a042581f4e29026704d',
+ )
+ await expect(page.locator('#storybook-root')).toHaveScreenshot()
+ })
+ test('has no a11y violations', async ({ page }, testInfo) => {
+ const accessibilityScanResults = await testA11yWithAttachedResults(page, testInfo, 'avatar')
+ await expect(accessibilityScanResults.violations).toEqual([])
+ })
+ test('changes avatar source when out of component button is clicked', async ({ page }) => {
+ await expect(page.getByAltText('avatar')).toHaveAttribute(
+ 'src',
+ 'https://i.pravatar.cc/300?u=a042581f4e29026704d',
+ )
+ await page.getByRole('button', { name: 'Change Source' }).click()
+ await expect(page.getByAltText('avatar')).toHaveAttribute(
+ 'src',
+ 'https://avatars.githubusercontent.com/u/6916170?v=4',
+ )
+ await expect(page.locator('#storybook-root')).toHaveScreenshot()
+ })
+ })
+}
diff --git a/tests/playwright/tests/packages/components/utils.ts b/tests/playwright/tests/packages/components/utils.ts
new file mode 100644
index 0000000000..2e1f7f16d9
--- /dev/null
+++ b/tests/playwright/tests/packages/components/utils.ts
@@ -0,0 +1,36 @@
+import AxeBuilder from '@axe-core/playwright'
+import type { Page, TestInfo } from '@playwright/test'
+
+export type PackageName = 'react' | 'vue'
+
+export const gotoStory = async (storyId: string, variantId: string, page: Page, packageName: PackageName) => {
+ if (!storyId || !variantId || !packageName) await page.goto('/')
+
+ // example urls:
+ // Vue: http://localhost:6007/iframe.html?globals=&id=components-avatar--basic&viewMode=story
+ // React: http://localhost:6007/iframe.html?args=&globals=&id=components-avatar--basic&viewMode=story
+ switch (packageName) {
+ case 'react':
+ await page.goto(`http://localhost:6006/iframe.html?id=components-${storyId}--${variantId}&viewMode=story`)
+ break
+ case 'vue':
+ await page.goto(`http://localhost:6009/iframe.html?id=components-${storyId}--${variantId}&viewMode=story`)
+ break
+ default:
+ await page.goto('/')
+ break
+ }
+}
+
+export const testA11yWithAttachedResults = async (page: Page, testInfo: TestInfo, componentName: string) => {
+ const accessibilityScanResults = await new AxeBuilder({ page })
+ .include(`[data-scope="${componentName}"][data-part="root"]`)
+ .disableRules('color-contrast') // This rule is not relevant since Ark components are not styled by default.
+ .analyze()
+
+ await testInfo.attach('accessibility-scan-results', {
+ body: JSON.stringify(accessibilityScanResults, null, 2),
+ contentType: 'application/json',
+ })
+ return accessibilityScanResults
+}