refactor(package/ui): eslint config it4c update (#9233)

This commit is contained in:
Ulf Gebhardt 2026-02-14 23:43:38 +01:00 committed by GitHub
parent 72714f58a6
commit 36e9ad6f80
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 315 additions and 387 deletions

View File

@ -1,23 +1,10 @@
// TODO: Update eslint-config-it4c to support ESLint 10 (currently incompatible)
import css from '@eslint/css'
import config, { vue3, vitest } from 'eslint-config-it4c'
import config, { css, vue3, vitest } from 'eslint-config-it4c'
import jsdocPlugin from 'eslint-plugin-jsdoc'
import playwrightPlugin from 'eslint-plugin-playwright'
import storybookPlugin from 'eslint-plugin-storybook'
import vuejsAccessibilityPlugin from 'eslint-plugin-vuejs-accessibility'
import { tailwind4 } from 'tailwind-csstree'
import type { Linter } from 'eslint'
/** Exclude CSS files from JS-focused config blocks (JS rules crash on CSS language) */
function excludeCSS(configs: Linter.Config[]): Linter.Config[] {
return configs.map((c) => {
// Don't touch global-ignores-only blocks
if (Object.keys(c).length === 1 && 'ignores' in c) return c
return { ...c, ignores: [...(c.ignores ?? []), '**/*.css'] }
})
}
export default [
{
ignores: [
@ -30,41 +17,13 @@ export default [
'playwright-report/',
],
},
...excludeCSS(config),
...excludeCSS(vue3),
...excludeCSS(vitest),
{
ignores: ['**/*.css'],
rules: {
// TODO: replace with alias
'import-x/no-relative-parent-imports': 'off',
},
},
{
// CLI scripts - allow sync methods and console
files: ['scripts/**/*.ts'],
rules: {
'n/shebang': 'off',
'n/no-sync': 'off',
'no-console': 'off',
'security/detect-non-literal-fs-filename': 'off',
},
},
...config,
...vue3,
...vitest,
{
// Playwright visual tests
files: ['**/*.visual.spec.ts'],
...playwrightPlugin.configs['flat/recommended'],
rules: {
'n/no-process-env': 'off',
'vitest/require-hook': 'off',
},
},
{
// Playwright config
files: ['playwright.config.ts'],
rules: {
'n/no-process-env': 'off',
},
},
// Storybook files
// eslint-disable-next-line import-x/no-named-as-default-member -- flat config access pattern
@ -96,18 +55,12 @@ export default [
...vuejsAccessibilityPlugin.configs.recommended.rules,
},
},
...css,
{
// CSS files with Tailwind v4 syntax support
// Extend CSS config with Tailwind v4 syntax support
files: ['**/*.css'],
plugins: { css },
language: 'css/css',
languageOptions: {
customSyntax: tailwind4,
},
rules: {
'css/no-empty-blocks': 'error',
'css/no-duplicate-imports': 'error',
'css/no-invalid-at-rules': 'error',
},
},
]

View File

@ -17,7 +17,6 @@
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.2",
"@axe-core/playwright": "^4.11.1",
"@eslint/css": "^0.14.1",
"@fontsource-variable/inter": "^5.2.8",
"@playwright/test": "^1.58.2",
"@size-limit/file": "^12.0.0",
@ -29,7 +28,7 @@
"@vitest/coverage-v8": "^4.0.18",
"@vue/test-utils": "^2.4.6",
"eslint": "^9.39.2",
"eslint-config-it4c": "^0.9.0",
"eslint-config-it4c": "^0.11.2",
"eslint-plugin-jsdoc": "^62.5.3",
"eslint-plugin-playwright": "^2.5.1",
"eslint-plugin-storybook": "^10.2.8",
@ -1317,21 +1316,6 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/css": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/@eslint/css/-/css-0.14.1.tgz",
"integrity": "sha512-NXiteSacmpaXqgyIW3+GcNzexXyfC0kd+gig6WTjD4A74kBGJeNx1tV0Hxa0v7x0+mnIyKfGPhGNs1uhRFdh+w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^0.17.0",
"@eslint/css-tree": "^3.6.6",
"@eslint/plugin-kit": "^0.4.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/css-tree": {
"version": "3.6.8",
"resolved": "https://registry.npmjs.org/@eslint/css-tree/-/css-tree-3.6.8.tgz",
@ -4256,17 +4240,17 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.54.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz",
"integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==",
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz",
"integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.54.0",
"@typescript-eslint/type-utils": "8.54.0",
"@typescript-eslint/utils": "8.54.0",
"@typescript-eslint/visitor-keys": "8.54.0",
"@typescript-eslint/scope-manager": "8.55.0",
"@typescript-eslint/type-utils": "8.55.0",
"@typescript-eslint/utils": "8.55.0",
"@typescript-eslint/visitor-keys": "8.55.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.4.0"
@ -4279,7 +4263,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.54.0",
"@typescript-eslint/parser": "^8.55.0",
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
@ -4295,16 +4279,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.54.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz",
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz",
"integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.54.0",
"@typescript-eslint/types": "8.54.0",
"@typescript-eslint/typescript-estree": "8.54.0",
"@typescript-eslint/visitor-keys": "8.54.0",
"@typescript-eslint/scope-manager": "8.55.0",
"@typescript-eslint/types": "8.55.0",
"@typescript-eslint/typescript-estree": "8.55.0",
"@typescript-eslint/visitor-keys": "8.55.0",
"debug": "^4.4.3"
},
"engines": {
@ -4320,14 +4304,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.54.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz",
"integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==",
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz",
"integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.54.0",
"@typescript-eslint/types": "^8.54.0",
"@typescript-eslint/tsconfig-utils": "^8.55.0",
"@typescript-eslint/types": "^8.55.0",
"debug": "^4.4.3"
},
"engines": {
@ -4342,14 +4326,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.54.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz",
"integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==",
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz",
"integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.54.0",
"@typescript-eslint/visitor-keys": "8.54.0"
"@typescript-eslint/types": "8.55.0",
"@typescript-eslint/visitor-keys": "8.55.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -4360,9 +4344,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.54.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz",
"integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==",
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz",
"integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==",
"dev": true,
"license": "MIT",
"engines": {
@ -4377,15 +4361,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.54.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz",
"integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==",
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz",
"integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.54.0",
"@typescript-eslint/typescript-estree": "8.54.0",
"@typescript-eslint/utils": "8.54.0",
"@typescript-eslint/types": "8.55.0",
"@typescript-eslint/typescript-estree": "8.55.0",
"@typescript-eslint/utils": "8.55.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.4.0"
},
@ -4402,9 +4386,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.54.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz",
"integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==",
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz",
"integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==",
"dev": true,
"license": "MIT",
"engines": {
@ -4416,16 +4400,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.54.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz",
"integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==",
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz",
"integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.54.0",
"@typescript-eslint/tsconfig-utils": "8.54.0",
"@typescript-eslint/types": "8.54.0",
"@typescript-eslint/visitor-keys": "8.54.0",
"@typescript-eslint/project-service": "8.55.0",
"@typescript-eslint/tsconfig-utils": "8.55.0",
"@typescript-eslint/types": "8.55.0",
"@typescript-eslint/visitor-keys": "8.55.0",
"debug": "^4.4.3",
"minimatch": "^9.0.5",
"semver": "^7.7.3",
@ -4470,16 +4454,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.54.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz",
"integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==",
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz",
"integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.54.0",
"@typescript-eslint/types": "8.54.0",
"@typescript-eslint/typescript-estree": "8.54.0"
"@typescript-eslint/scope-manager": "8.55.0",
"@typescript-eslint/types": "8.55.0",
"@typescript-eslint/typescript-estree": "8.55.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -4494,13 +4478,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.54.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz",
"integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==",
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz",
"integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.54.0",
"@typescript-eslint/types": "8.55.0",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
@ -4828,6 +4812,33 @@
}
}
},
"node_modules/@vitest/eslint-plugin": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.7.tgz",
"integrity": "sha512-sd2QJirEscSQk3Pywtelbs7z8RQp1gyF5BfeZVtTHE8y3suyzbAA71NuT9z01uTRMHoCf5p6M2t2WYNJ7m5FlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "^8.55.0",
"@typescript-eslint/utils": "^8.55.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"eslint": ">=8.57.0",
"typescript": ">=5.0.0",
"vitest": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
},
"vitest": {
"optional": true
}
}
},
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
@ -7072,14 +7083,16 @@
}
},
"node_modules/eslint-config-it4c": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/eslint-config-it4c/-/eslint-config-it4c-0.9.0.tgz",
"integrity": "sha512-iPcSbv3/dmrMRidPSugJguJejeiPL41uW79i/faRX55p4s2t5XZO+WvDcJu+vil7wTtpz8LJGjwz23VvK+FycQ==",
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/eslint-config-it4c/-/eslint-config-it4c-0.11.2.tgz",
"integrity": "sha512-2ZiGvcjQAQPUbvLns83WGvnFlVupsVFJF9erCFYbR2ktKDvkTxqBxQEh6b4d4CDOhIBqAabkphtBkmdOF6SYCw==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-plugin-eslint-comments": "^4.6.0",
"@eslint/css": "^0.7.0",
"@eslint/js": "^9.39.2",
"@graphql-eslint/eslint-plugin": "^4.4.0",
"@vitest/eslint-plugin": "^1.6.7",
"@vue/eslint-config-typescript": "^14.6.0",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
@ -7089,16 +7102,57 @@
"eslint-plugin-no-catch-all": "^1.1.0",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-security": "^3.0.1",
"eslint-plugin-vitest": "^0.5.4",
"eslint-plugin-vue": "^10.7.0",
"eslint-plugin-yml": "^3.0.0",
"eslint-plugin-vue": "^10.8.0",
"eslint-plugin-yml": "^3.1.2",
"neostandard": "^0.12.2",
"typescript-eslint": "^8.54.0"
"typescript-eslint": "^8.55.0"
},
"peerDependencies": {
"eslint": ">= 9"
}
},
"node_modules/eslint-config-it4c/node_modules/@eslint/core": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz",
"integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@types/json-schema": "^7.0.15"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/eslint-config-it4c/node_modules/@eslint/css": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@eslint/css/-/css-0.7.0.tgz",
"integrity": "sha512-d6mo8etv4igrTGxgvWSgA5+TsppfObM/Xhlu8JWbkqNBiaJXztUNH45R1B4i1GL2PNIFMLREI3Kh9lTBi19l7g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^0.13.0",
"@eslint/css-tree": "^3.3.3",
"@eslint/plugin-kit": "^0.2.5"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/eslint-config-it4c/node_modules/@eslint/plugin-kit": {
"version": "0.2.8",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz",
"integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^0.13.0",
"levn": "^0.4.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/eslint-config-prettier": {
"version": "10.1.8",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
@ -7607,189 +7661,10 @@
"storybook": "^10.2.8"
}
},
"node_modules/eslint-plugin-vitest": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/eslint-plugin-vitest/-/eslint-plugin-vitest-0.5.4.tgz",
"integrity": "sha512-um+odCkccAHU53WdKAw39MY61+1x990uXjSPguUCq3VcEHdqJrOb8OTMrbYlY6f9jAKx7x98kLVlIe3RJeJqoQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/utils": "^7.7.1"
},
"engines": {
"node": "^18.0.0 || >= 20.0.0"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"vitest": "*"
},
"peerDependenciesMeta": {
"@typescript-eslint/eslint-plugin": {
"optional": true
},
"vitest": {
"optional": true
}
}
},
"node_modules/eslint-plugin-vitest/node_modules/@typescript-eslint/scope-manager": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz",
"integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "7.18.0",
"@typescript-eslint/visitor-keys": "7.18.0"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/eslint-plugin-vitest/node_modules/@typescript-eslint/types": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz",
"integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/eslint-plugin-vitest/node_modules/@typescript-eslint/typescript-estree": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz",
"integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@typescript-eslint/types": "7.18.0",
"@typescript-eslint/visitor-keys": "7.18.0",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
"minimatch": "^9.0.4",
"semver": "^7.6.0",
"ts-api-utils": "^1.3.0"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/eslint-plugin-vitest/node_modules/@typescript-eslint/utils": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz",
"integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@typescript-eslint/scope-manager": "7.18.0",
"@typescript-eslint/types": "7.18.0",
"@typescript-eslint/typescript-estree": "7.18.0"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.56.0"
}
},
"node_modules/eslint-plugin-vitest/node_modules/@typescript-eslint/visitor-keys": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz",
"integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "7.18.0",
"eslint-visitor-keys": "^3.4.3"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/eslint-plugin-vitest/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/eslint-plugin-vitest/node_modules/eslint-visitor-keys": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-plugin-vitest/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/eslint-plugin-vitest/node_modules/ts-api-utils": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
"integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16"
},
"peerDependencies": {
"typescript": ">=4.2.0"
}
},
"node_modules/eslint-plugin-vue": {
"version": "10.7.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.7.0.tgz",
"integrity": "sha512-r2XFCK4qlo1sxEoAMIoTTX0PZAdla0JJDt1fmYiworZUX67WeEGqm+JbyAg3M+pGiJ5U6Mp5WQbontXWtIW7TA==",
"version": "10.8.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.8.0.tgz",
"integrity": "sha512-f1J/tcbnrpgC8suPN5AtdJ5MQjuXbSU9pGRSSYAuF3SHoiYCOdEX6O22pLaRyLHXvDcOe+O5ENgc1owQ587agA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -7806,7 +7681,7 @@
"peerDependencies": {
"@stylistic/eslint-plugin": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0",
"@typescript-eslint/parser": "^7.0.0 || ^8.0.0",
"eslint": "^8.57.0 || ^9.0.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"vue-eslint-parser": "^10.0.0"
},
"peerDependenciesMeta": {
@ -13323,16 +13198,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.54.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.54.0.tgz",
"integrity": "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==",
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.55.0.tgz",
"integrity": "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.54.0",
"@typescript-eslint/parser": "8.54.0",
"@typescript-eslint/typescript-estree": "8.54.0",
"@typescript-eslint/utils": "8.54.0"
"@typescript-eslint/eslint-plugin": "8.55.0",
"@typescript-eslint/parser": "8.55.0",
"@typescript-eslint/typescript-estree": "8.55.0",
"@typescript-eslint/utils": "8.55.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"

View File

@ -4,6 +4,9 @@
"description": "Vue component library for ocelot.social - works with Vue 2.7+ and Vue 3",
"license": "Apache-2.0",
"type": "module",
"imports": {
"#src/*": "./src/*"
},
"exports": {
".": {
"import": {
@ -72,7 +75,6 @@
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.2",
"@axe-core/playwright": "^4.11.1",
"@eslint/css": "^0.14.1",
"@fontsource-variable/inter": "^5.2.8",
"@playwright/test": "^1.58.2",
"@size-limit/file": "^12.0.0",
@ -84,7 +86,7 @@
"@vitest/coverage-v8": "^4.0.18",
"@vue/test-utils": "^2.4.6",
"eslint": "^9.39.2",
"eslint-config-it4c": "^0.9.0",
"eslint-config-it4c": "^0.11.2",
"eslint-plugin-jsdoc": "^62.5.3",
"eslint-plugin-playwright": "^2.5.1",
"eslint-plugin-storybook": "^10.2.8",

View File

@ -1,3 +1,4 @@
/* eslint-disable n/no-process-env */
import { defineConfig, devices } from '@playwright/test'
/**

View File

@ -1,4 +1,4 @@
#!/usr/bin/env npx tsx
/* eslint-disable no-console */
/**
* Completeness checker for @ocelot-social/ui components
*
@ -13,7 +13,7 @@
* Note: JSDoc comments on props are checked via ESLint (jsdoc/require-jsdoc)
*/
import { existsSync, readFileSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import { glob } from 'glob'
@ -25,6 +25,17 @@ function toKebabCase(str: string): string {
return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
}
/** Read file contents, returning null if the file does not exist */
async function tryReadFile(path: string): Promise<string | null> {
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- path from glob, not user input
return await readFile(path, 'utf-8')
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
throw error
}
}
interface CheckResult {
component: string
errors: string[]
@ -35,7 +46,7 @@ const results: CheckResult[] = []
let hasErrors = false
// Find all Vue components (excluding index files)
const components = glob.sync('src/components/**/Os*.vue')
const components = await glob('src/components/**/Os*.vue')
for (const componentPath of components) {
const componentName = basename(componentPath, '.vue')
@ -54,28 +65,32 @@ for (const componentPath of components) {
warnings: [],
}
// Read all files once (null = file does not exist)
const [storyContent, visualTestContent, unitTestContent, variantsContent] = await Promise.all([
tryReadFile(storyPath),
tryReadFile(visualTestPath),
tryReadFile(unitTestPath),
tryReadFile(variantsPath),
])
// Check 1: Story file exists
if (!existsSync(storyPath)) {
if (storyContent === null) {
result.errors.push(`Missing story file: ${storyPath}`)
}
// Check 2: Visual regression test file exists
if (!existsSync(visualTestPath)) {
if (visualTestContent === null) {
result.errors.push(`Missing visual test file: ${visualTestPath}`)
}
// Check 3: Visual tests include accessibility checks
if (existsSync(visualTestPath)) {
const visualTestContent = readFileSync(visualTestPath, 'utf-8')
if (!visualTestContent.includes('checkA11y(')) {
result.errors.push(`Missing checkA11y() calls in visual tests: ${visualTestPath}`)
}
if (visualTestContent !== null && !visualTestContent.includes('checkA11y(')) {
result.errors.push(`Missing checkA11y() calls in visual tests: ${visualTestPath}`)
}
// Check 4: Keyboard accessibility tests exist
if (existsSync(unitTestPath)) {
const unitTestContent = readFileSync(unitTestPath, 'utf-8')
if (!unitTestContent.includes("describe('keyboard accessibility'")) {
if (unitTestContent !== null) {
if (!/describe\(\s*['"]keyboard accessibility['"]/.test(unitTestContent)) {
result.errors.push(`Missing keyboard accessibility tests in: ${unitTestPath}`)
}
} else {
@ -83,18 +98,14 @@ for (const componentPath of components) {
}
// Check 5 & 6: Story and visual test coverage
if (existsSync(storyPath) && existsSync(visualTestPath)) {
const storyContent = readFileSync(storyPath, 'utf-8')
const visualTestContent = readFileSync(visualTestPath, 'utf-8')
// Extract exported story names (e.g., "export const Primary: Story")
if (storyContent !== null && visualTestContent !== null) {
const storyExports = storyContent.matchAll(/export\s+const\s+(\w+):\s*Story/g)
for (const match of storyExports) {
const storyName = match[1]
if (storyName === 'Playground') continue
const kebabName = toKebabCase(storyName)
// Check if this story is tested in visual tests (URL pattern: --story-name)
if (!visualTestContent.includes(`--${kebabName}`)) {
result.warnings.push(`Story "${storyName}" missing visual test (--${kebabName})`)
}
@ -102,29 +113,20 @@ for (const componentPath of components) {
}
// Check 5: Variant values are demonstrated in stories
if (existsSync(storyPath) && existsSync(variantsPath)) {
const variantsContent = readFileSync(variantsPath, 'utf-8')
const storyContent = readFileSync(storyPath, 'utf-8')
// Extract variants block
if (storyContent !== null && variantsContent !== null) {
const variantsBlockMatch = /variants:\s*\{([\s\S]*?)\n\s{4}\},/m.exec(variantsContent)
if (variantsBlockMatch) {
const variantsBlock = variantsBlockMatch[1]
// Extract each variant type (variant, size, etc.)
const variantTypeMatches = variantsBlock.matchAll(/^\s{6}(\w+):\s*\{([\s\S]*?)\n\s{6}\}/gm)
for (const match of variantTypeMatches) {
const variantName = match[1]
const variantValues = match[2]
// Extract individual values
const valueMatches = variantValues.matchAll(/^\s+(\w+):\s*\[/gm)
for (const valueMatch of valueMatches) {
const value = valueMatch[1]
// Check if this value appears in stories (multiple patterns)
const patterns = [
`${variantName}="${value}"`,
`${variantName}='${value}'`,
@ -132,8 +134,7 @@ for (const componentPath of components) {
`${variantName}: "${value}"`,
]
const found = patterns.some((p) => storyContent.includes(p))
if (!found) {
if (!patterns.some((p) => storyContent.includes(p))) {
result.warnings.push(`Variant "${variantName}=${value}" not demonstrated in story`)
}
}

View File

@ -8,12 +8,14 @@ describe('osButton', () => {
const wrapper = mount(OsButton, {
slots: { default: 'Click me' },
})
expect(wrapper.text()).toBe('Click me')
})
describe('variant prop', () => {
it('applies default variant classes by default', () => {
const wrapper = mount(OsButton)
// Default variant with filled appearance
expect(wrapper.classes()).toContain('bg-[var(--color-default)]')
})
@ -22,6 +24,7 @@ describe('osButton', () => {
const wrapper = mount(OsButton, {
props: { variant: 'primary' },
})
expect(wrapper.classes()).toContain('bg-[var(--color-primary)]')
})
@ -29,6 +32,7 @@ describe('osButton', () => {
const wrapper = mount(OsButton, {
props: { variant: 'danger' },
})
expect(wrapper.classes()).toContain('bg-[var(--color-danger)]')
})
})
@ -36,6 +40,7 @@ describe('osButton', () => {
describe('appearance prop', () => {
it('applies filled appearance by default', () => {
const wrapper = mount(OsButton)
expect(wrapper.classes()).toContain('shadow-[inset_0_0_0_1px_rgba(0,0,0,0.05)]')
})
@ -43,6 +48,7 @@ describe('osButton', () => {
const wrapper = mount(OsButton, {
props: { appearance: 'outline', variant: 'primary' },
})
expect(wrapper.classes()).toContain('bg-transparent')
expect(wrapper.classes()).toContain('border-[var(--color-primary)]')
expect(wrapper.classes()).toContain('text-[var(--color-primary)]')
@ -52,6 +58,7 @@ describe('osButton', () => {
const wrapper = mount(OsButton, {
props: { appearance: 'ghost', variant: 'primary' },
})
expect(wrapper.classes()).toContain('bg-transparent')
expect(wrapper.classes()).toContain('text-[var(--color-primary)]')
expect(wrapper.classes()).not.toContain('border-[var(--color-primary)]')
@ -61,6 +68,7 @@ describe('osButton', () => {
describe('size prop', () => {
it('applies md size by default', () => {
const wrapper = mount(OsButton)
expect(wrapper.classes()).toContain('h-[36px]')
})
@ -68,6 +76,7 @@ describe('osButton', () => {
const wrapper = mount(OsButton, {
props: { size: 'sm' },
})
expect(wrapper.classes()).toContain('h-[26px]')
expect(wrapper.classes()).toContain('text-[12px]')
})
@ -76,6 +85,7 @@ describe('osButton', () => {
const wrapper = mount(OsButton, {
props: { size: 'lg' },
})
expect(wrapper.classes()).toContain('h-12')
})
@ -88,6 +98,7 @@ describe('osButton', () => {
} as const
for (const [size, expected] of Object.entries(sizes)) {
const wrapper = mount(OsButton, { props: { size: size as keyof typeof sizes } })
expect(wrapper.classes()).toContain(expected)
}
})
@ -97,6 +108,7 @@ describe('osButton', () => {
const wrapper = mount(OsButton, {
props: { fullWidth: true },
})
expect(wrapper.classes()).toContain('w-full')
})
@ -104,6 +116,7 @@ describe('osButton', () => {
const wrapper = mount(OsButton, {
attrs: { class: 'my-custom-class' },
})
expect(wrapper.classes()).toContain('my-custom-class')
})
@ -111,11 +124,13 @@ describe('osButton', () => {
const wrapper = mount(OsButton, {
props: { disabled: true },
})
expect(wrapper.attributes('disabled')).toBeDefined()
})
it('defaults to type="button"', () => {
const wrapper = mount(OsButton)
expect(wrapper.attributes('type')).toBe('button')
})
@ -123,6 +138,7 @@ describe('osButton', () => {
const wrapper = mount(OsButton, {
props: { type: 'submit' },
})
expect(wrapper.attributes('type')).toBe('submit')
})
@ -130,6 +146,7 @@ describe('osButton', () => {
const wrapper = mount(OsButton, {
props: { variant: 'danger' },
})
expect(wrapper.attributes('data-variant')).toBe('danger')
})
@ -137,18 +154,21 @@ describe('osButton', () => {
const wrapper = mount(OsButton, {
props: { appearance: 'outline' },
})
expect(wrapper.attributes('data-appearance')).toBe('outline')
})
it('emits click event', async () => {
const wrapper = mount(OsButton)
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toHaveLength(1)
})
describe('focus styles', () => {
it('default variant has dashed outline focus style using currentColor', () => {
const wrapper = mount(OsButton)
expect(wrapper.classes()).toContain('focus:outline-dashed')
expect(wrapper.classes()).toContain('focus:outline-current')
expect(wrapper.classes()).toContain('focus:outline-1')
@ -158,6 +178,7 @@ describe('osButton', () => {
const wrapper = mount(OsButton, {
props: { variant: 'primary' },
})
expect(wrapper.classes()).toContain('focus:outline-dashed')
expect(wrapper.classes()).toContain('focus:outline-1')
})
@ -169,8 +190,9 @@ describe('osButton', () => {
slots: { icon: '<svg data-testid="icon"></svg>' },
})
const iconWrapper = wrapper.find('.os-button__icon')
expect(iconWrapper.exists()).toBeTruthy()
expect(iconWrapper.find('[data-testid="icon"]').exists()).toBeTruthy()
expect(iconWrapper.exists()).toBe(true)
expect(iconWrapper.find('[data-testid="icon"]').exists()).toBe(true)
})
it('renders both icon and text', () => {
@ -180,7 +202,8 @@ describe('osButton', () => {
default: 'Save',
},
})
expect(wrapper.find('.os-button__icon').exists()).toBeTruthy()
expect(wrapper.find('.os-button__icon').exists()).toBe(true)
expect(wrapper.text()).toContain('Save')
})
@ -192,6 +215,7 @@ describe('osButton', () => {
},
})
const contentSpan = wrapper.find('button > span')
expect(contentSpan.classes()).toContain('gap-2')
})
@ -204,6 +228,7 @@ describe('osButton', () => {
},
})
const contentSpan = wrapper.find('button > span')
expect(contentSpan.classes()).toContain('gap-1')
expect(contentSpan.classes()).not.toContain('gap-2')
})
@ -213,6 +238,7 @@ describe('osButton', () => {
slots: { icon: '<svg></svg>' },
})
const contentSpan = wrapper.find('button > span')
expect(contentSpan.classes()).not.toContain('gap-2')
})
@ -224,6 +250,7 @@ describe('osButton', () => {
},
})
const contentSpan = wrapper.find('button > span')
expect(contentSpan.classes()).not.toContain('gap-2')
expect(wrapper.find('.os-button__icon').classes()).toContain('-mr-1')
})
@ -233,6 +260,7 @@ describe('osButton', () => {
slots: { default: 'Click me' },
})
const contentSpan = wrapper.find('button > span')
expect(contentSpan.classes()).not.toContain('gap-2')
})
@ -240,7 +268,8 @@ describe('osButton', () => {
const wrapper = mount(OsButton, {
slots: { default: 'Click me' },
})
expect(wrapper.find('.os-button__icon').exists()).toBeFalsy()
expect(wrapper.find('.os-button__icon').exists()).toBe(false)
expect(wrapper.text()).toBe('Click me')
})
@ -248,7 +277,8 @@ describe('osButton', () => {
const wrapper = mount(OsButton, {
slots: { icon: '<svg></svg>' },
})
expect(wrapper.find('.os-button__icon').exists()).toBeTruthy()
expect(wrapper.find('.os-button__icon').exists()).toBe(true)
expect(wrapper.text()).toBe('')
})
})
@ -260,6 +290,7 @@ describe('osButton', () => {
slots: { icon: '<svg></svg>' },
attrs: { 'aria-label': 'Add' },
})
expect(wrapper.classes()).toContain('rounded-full')
expect(wrapper.classes()).toContain('p-0')
})
@ -270,6 +301,7 @@ describe('osButton', () => {
slots: { icon: '<svg></svg>' },
attrs: { 'aria-label': 'Add' },
})
expect(wrapper.classes()).toContain('w-[36px]')
})
@ -279,6 +311,7 @@ describe('osButton', () => {
slots: { icon: '<svg></svg>' },
attrs: { 'aria-label': 'Add' },
})
expect(wrapper.classes()).toContain('w-[26px]')
})
@ -288,6 +321,7 @@ describe('osButton', () => {
slots: { icon: '<svg></svg>' },
attrs: { 'aria-label': 'Add' },
})
expect(wrapper.classes()).toContain('w-12')
})
@ -297,6 +331,7 @@ describe('osButton', () => {
slots: { icon: '<svg></svg>' },
attrs: { 'aria-label': 'Add' },
})
expect(wrapper.classes()).toContain('w-14')
})
@ -306,6 +341,7 @@ describe('osButton', () => {
slots: { icon: '<svg></svg>' },
attrs: { 'aria-label': 'Add' },
})
expect(wrapper.classes()).toContain('rounded-full')
expect(wrapper.classes()).toContain('bg-[var(--color-primary)]')
})
@ -316,6 +352,7 @@ describe('osButton', () => {
slots: { icon: '<svg></svg>' },
attrs: { 'aria-label': 'Add' },
})
expect(wrapper.classes()).toContain('rounded-full')
expect(wrapper.classes()).toContain('bg-transparent')
})
@ -327,6 +364,7 @@ describe('osButton', () => {
attrs: { 'aria-label': 'Add' },
})
const iconWrapper = wrapper.find('.os-button__icon')
expect(iconWrapper.classes()).not.toContain('-ml-1')
expect(iconWrapper.classes()).not.toContain('-mr-1')
})
@ -340,6 +378,7 @@ describe('osButton', () => {
},
})
const contentSpan = wrapper.find('button > span')
expect(contentSpan.classes()).toContain('gap-1')
expect(contentSpan.classes()).not.toContain('gap-2')
})
@ -349,6 +388,7 @@ describe('osButton', () => {
props: { circle: false },
slots: { default: 'Click me' },
})
expect(wrapper.classes()).not.toContain('rounded-full')
expect(wrapper.classes()).not.toContain('p-0')
})
@ -360,8 +400,9 @@ describe('osButton', () => {
props: { loading: true },
slots: { default: 'Save' },
})
expect(wrapper.find('.os-button__spinner').exists()).toBeTruthy()
expect(wrapper.find('svg').exists()).toBeTruthy()
expect(wrapper.find('.os-button__spinner').exists()).toBe(true)
expect(wrapper.find('svg').exists()).toBe(true)
})
it('disables button when loading=true', () => {
@ -369,6 +410,7 @@ describe('osButton', () => {
props: { loading: true },
slots: { default: 'Save' },
})
expect(wrapper.attributes('disabled')).toBeDefined()
})
@ -377,6 +419,7 @@ describe('osButton', () => {
props: { loading: true },
slots: { default: 'Save' },
})
expect(wrapper.attributes('aria-busy')).toBe('true')
})
@ -386,6 +429,7 @@ describe('osButton', () => {
slots: { default: 'Save' },
})
const contentSpan = wrapper.find('span')
expect(contentSpan.classes()).not.toContain('opacity-0')
expect(wrapper.text()).toContain('Save')
})
@ -394,13 +438,15 @@ describe('osButton', () => {
const wrapper = mount(OsButton, {
slots: { default: 'Save' },
})
expect(wrapper.find('.os-button__spinner').exists()).toBeFalsy()
expect(wrapper.find('.os-button__spinner').exists()).toBe(false)
})
it('does not set aria-busy when not loading', () => {
const wrapper = mount(OsButton, {
slots: { default: 'Save' },
})
expect(wrapper.attributes('aria-busy')).toBeUndefined()
})
@ -409,6 +455,7 @@ describe('osButton', () => {
props: { loading: true, disabled: true },
slots: { default: 'Save' },
})
expect(wrapper.attributes('disabled')).toBeDefined()
})
@ -418,6 +465,7 @@ describe('osButton', () => {
slots: { default: 'Save' },
})
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeUndefined()
})
@ -430,8 +478,9 @@ describe('osButton', () => {
},
})
const iconWrapper = wrapper.find('.os-button__icon')
expect(iconWrapper.exists()).toBeTruthy()
expect(iconWrapper.find('.os-button__spinner').exists()).toBeTruthy()
expect(iconWrapper.exists()).toBe(true)
expect(iconWrapper.find('.os-button__spinner').exists()).toBe(true)
})
it('keeps icon visible when loading with icon', () => {
@ -443,6 +492,7 @@ describe('osButton', () => {
},
})
const iconWrapper = wrapper.find('.os-button__icon')
expect(iconWrapper.classes()).not.toContain('[&>*]:invisible')
})
@ -453,7 +503,8 @@ describe('osButton', () => {
})
// Spinner is a direct child of button, not inside content wrapper
const spinner = wrapper.find('button > .os-button__spinner')
expect(spinner.exists()).toBeTruthy()
expect(spinner.exists()).toBe(true)
})
it('does not render button-level spinner when icon is present', () => {
@ -466,7 +517,8 @@ describe('osButton', () => {
})
// No spinner as direct child of button — it's inside the icon wrapper
const buttonSpinner = wrapper.find('button > .os-button__spinner')
expect(buttonSpinner.exists()).toBeFalsy()
expect(buttonSpinner.exists()).toBe(false)
})
it('keeps icon visible and shows spinner for icon-only loading', () => {
@ -475,9 +527,10 @@ describe('osButton', () => {
slots: { icon: '<svg data-testid="icon"></svg>' },
})
const iconWrapper = wrapper.find('.os-button__icon')
expect(iconWrapper.exists()).toBeTruthy()
expect(iconWrapper.find('[data-testid="icon"]').exists()).toBeTruthy()
expect(iconWrapper.find('.os-button__spinner').exists()).toBeTruthy()
expect(iconWrapper.exists()).toBe(true)
expect(iconWrapper.find('[data-testid="icon"]').exists()).toBe(true)
expect(iconWrapper.find('.os-button__spinner').exists()).toBe(true)
expect(iconWrapper.classes()).not.toContain('[&>*]:invisible')
})
@ -487,8 +540,9 @@ describe('osButton', () => {
slots: { icon: '<svg></svg>' },
attrs: { 'aria-label': 'Add' },
})
expect(wrapper.classes()).toContain('rounded-full')
expect(wrapper.find('.os-button__spinner').exists()).toBeTruthy()
expect(wrapper.find('.os-button__spinner').exists()).toBe(true)
expect(wrapper.attributes('disabled')).toBeDefined()
expect(wrapper.attributes('aria-busy')).toBe('true')
})
@ -497,12 +551,14 @@ describe('osButton', () => {
describe('keyboard accessibility', () => {
it('renders as native button element for keyboard support', () => {
const wrapper = mount(OsButton)
// Native button elements have built-in Enter/Space key support
expect((wrapper.element as HTMLElement).tagName).toBe('BUTTON')
})
it('is focusable by default', () => {
const wrapper = mount(OsButton)
// No tabindex=-1 means button is in natural tab order
expect(wrapper.attributes('tabindex')).toBeUndefined()
})
@ -511,6 +567,7 @@ describe('osButton', () => {
const wrapper = mount(OsButton, {
props: { disabled: true },
})
// Disabled buttons have disabled attribute which browsers handle correctly
expect(wrapper.attributes('disabled')).toBeDefined()
})
@ -520,6 +577,7 @@ describe('osButton', () => {
slots: { icon: '<svg></svg>' },
attrs: { 'aria-label': 'Close' },
})
expect(wrapper.attributes('aria-label')).toBe('Close')
expect(wrapper.attributes('tabindex')).toBeUndefined()
})
@ -528,7 +586,9 @@ describe('osButton', () => {
const wrapper = mount(OsButton, { attachTo: document.body })
const button = wrapper.element as HTMLButtonElement
button.focus()
expect(document.activeElement).toBe(button)
wrapper.unmount()
})
})

View File

@ -38,6 +38,7 @@ test.describe('OsButton keyboard accessibility', () => {
const buttons = root.locator('button:not([disabled])')
const count = await buttons.count()
expect(count).toBeGreaterThan(0)
for (let i = 0; i < count; i++) {
@ -45,6 +46,7 @@ test.describe('OsButton keyboard accessibility', () => {
await button.focus()
const outline = await button.evaluate((el) => getComputedStyle(el).outlineStyle)
const label = (await button.textContent()) ?? ''
expect(outline, `Button "${label}" must have visible focus outline`).not.toBe('none')
}
})
@ -56,7 +58,9 @@ test.describe('OsButton visual regression', () => {
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('.flex')).toHaveScreenshot('all-variants.png')
await checkA11y(page)
})
@ -65,7 +69,9 @@ test.describe('OsButton visual regression', () => {
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('.flex-col').first()).toHaveScreenshot('all-sizes.png')
await checkA11y(page)
})
@ -74,7 +80,9 @@ test.describe('OsButton visual regression', () => {
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('.flex')).toHaveScreenshot('appearance-filled.png')
await checkA11y(page)
})
@ -83,7 +91,9 @@ test.describe('OsButton visual regression', () => {
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('.flex')).toHaveScreenshot('appearance-outline.png')
await checkA11y(page)
})
@ -92,7 +102,9 @@ test.describe('OsButton visual regression', () => {
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('.flex')).toHaveScreenshot('appearance-ghost.png')
await checkA11y(page)
})
@ -101,7 +113,9 @@ test.describe('OsButton visual regression', () => {
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('.flex-col').first()).toHaveScreenshot('all-appearances.png')
await checkA11y(page)
})
@ -110,7 +124,9 @@ test.describe('OsButton visual regression', () => {
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('.flex-col').first()).toHaveScreenshot('disabled.png')
await checkA11y(page)
})
@ -119,7 +135,9 @@ test.describe('OsButton visual regression', () => {
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('.flex-col').first()).toHaveScreenshot('full-width.png')
await checkA11y(page)
})
@ -128,7 +146,9 @@ test.describe('OsButton visual regression', () => {
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('.flex')).toHaveScreenshot('icon.png')
await checkA11y(page)
})
@ -137,7 +157,9 @@ test.describe('OsButton visual regression', () => {
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('.flex')).toHaveScreenshot('icon-only.png')
await checkA11y(page)
})
@ -146,7 +168,9 @@ test.describe('OsButton visual regression', () => {
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('.flex')).toHaveScreenshot('icon-sizes.png')
await checkA11y(page)
})
@ -155,7 +179,9 @@ test.describe('OsButton visual regression', () => {
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('.flex-col').first()).toHaveScreenshot('icon-appearances.png')
await checkA11y(page)
})
@ -164,7 +190,9 @@ test.describe('OsButton visual regression', () => {
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('.flex')).toHaveScreenshot('circle.png')
await checkA11y(page)
})
@ -173,7 +201,9 @@ test.describe('OsButton visual regression', () => {
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('.flex-col').first()).toHaveScreenshot('circle-sizes.png')
await checkA11y(page)
})
@ -182,7 +212,9 @@ test.describe('OsButton visual regression', () => {
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('.flex-col').first()).toHaveScreenshot('circle-appearances.png')
await checkA11y(page)
})
@ -200,7 +232,9 @@ test.describe('OsButton visual regression', () => {
;(el as HTMLElement).style.animationPlayState = 'paused'
})
})
await expect(root.locator('.flex-col').first()).toHaveScreenshot('loading.png')
await checkA11y(page)
})
})

View File

@ -1,7 +1,7 @@
<script lang="ts">
import { computed, defineComponent, getCurrentInstance, h, isVue2 } from 'vue-demi'
import { cn } from '../../utils'
import { cn } from '#src/utils'
import { buttonVariants } from './button.variants'

View File

@ -11,7 +11,7 @@ describe('ocelotUI Plugin', () => {
it('registers only Os-prefixed components', () => {
const mockApp = {
component: vi.fn(),
component: vi.fn<(name: string, component: unknown) => void>(),
}
OcelotUI.install?.(mockApp as never)
@ -28,23 +28,24 @@ describe('ocelotUI Plugin', () => {
it('does not register non-component exports', () => {
const mockApp = {
component: vi.fn(),
component: vi.fn<(name: string, component: unknown) => void>(),
}
OcelotUI.install?.(mockApp as never)
// buttonVariants should NOT be registered
const callArgs = mockApp.component.mock.calls.map((call: unknown[]) => call[0])
const callArgs = mockApp.component.mock.calls.map((call) => call[0])
expect(callArgs).not.toContain('buttonVariants')
})
it('works without throwing', () => {
const mockApp = {
component: vi.fn(),
component: vi.fn<(name: string, component: unknown) => void>(),
}
expect(() => {
OcelotUI.install?.(mockApp as never)
}).not.toThrow()
}).not.toThrowError()
})
})

View File

@ -1,4 +1,4 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { describe, it, expect, vi, afterEach, expectTypeOf } from 'vitest'
import { ocelotPreset, requiredCssVariables, validateCssVariables } from './tailwind.preset'
@ -13,26 +13,27 @@ describe('tailwind.preset', () => {
describe('requiredCssVariables', () => {
it('exports an array', () => {
expect(Array.isArray(requiredCssVariables)).toBeTruthy()
expect(Array.isArray(requiredCssVariables)).toBe(true)
})
it('contains only strings', () => {
for (const variable of requiredCssVariables) {
expect(typeof variable).toBe('string')
expectTypeOf(variable).toBeString()
}
})
it('all variables start with --', () => {
// This test validates the format constraint.
for (const variable of requiredCssVariables) {
expect(variable.startsWith('--')).toBeTruthy()
expect(variable.startsWith('--')).toBe(true)
}
// Ensure test runs even with empty array
expect(requiredCssVariables.every((v) => v.startsWith('--'))).toBeTruthy()
expect(requiredCssVariables.every((v) => v.startsWith('--'))).toBe(true)
})
})
describe('validateCssVariables', () => {
describe(validateCssVariables, () => {
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
@ -43,12 +44,12 @@ describe('tailwind.preset', () => {
expect(() => {
validateCssVariables()
}).not.toThrow()
}).not.toThrowError()
})
it('does not warn when all variables are defined', () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const mockGetPropertyValue = vi.fn().mockReturnValue('some-value')
const mockGetPropertyValue = vi.fn<(prop: string) => string>().mockReturnValue('some-value')
vi.stubGlobal('window', {})
vi.stubGlobal('document', {
@ -65,7 +66,7 @@ describe('tailwind.preset', () => {
it('warns when variables are missing', () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const mockGetPropertyValue = vi.fn().mockReturnValue('')
const mockGetPropertyValue = vi.fn<(prop: string) => string>().mockReturnValue('')
vi.stubGlobal('window', {})
vi.stubGlobal('document', {

View File

@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { cn } from './cn'
describe('cn', () => {
describe(cn, () => {
it('merges class names', () => {
expect(cn('foo', 'bar')).toBe('foo bar')
})

View File

@ -24,7 +24,7 @@
/* Paths */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
"#src/*": ["src/*"]
},
/* Types */