From 36e9ad6f80839bcca80e2c9926750e446cc33f82 Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Sat, 14 Feb 2026 23:43:38 +0100 Subject: [PATCH] refactor(package/ui): eslint config it4c update (#9233) --- packages/ui/eslint.config.ts | 59 +-- packages/ui/package-lock.json | 411 ++++++------------ packages/ui/package.json | 6 +- packages/ui/playwright.config.ts | 1 + packages/ui/scripts/check-completeness.ts | 63 +-- .../src/components/OsButton/OsButton.spec.ts | 92 +++- .../OsButton/OsButton.visual.spec.ts | 34 ++ .../ui/src/components/OsButton/OsButton.vue | 2 +- packages/ui/src/plugin.spec.ts | 11 +- packages/ui/src/tailwind.preset.spec.ts | 19 +- packages/ui/src/utils/cn.spec.ts | 2 +- packages/ui/tsconfig.json | 2 +- 12 files changed, 315 insertions(+), 387 deletions(-) diff --git a/packages/ui/eslint.config.ts b/packages/ui/eslint.config.ts index 3dad3c77a..1d63cd448 100644 --- a/packages/ui/eslint.config.ts +++ b/packages/ui/eslint.config.ts @@ -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', - }, }, ] diff --git a/packages/ui/package-lock.json b/packages/ui/package-lock.json index c598d24ab..e519a342e 100644 --- a/packages/ui/package-lock.json +++ b/packages/ui/package-lock.json @@ -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" diff --git a/packages/ui/package.json b/packages/ui/package.json index fd808870b..76d244b20 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", diff --git a/packages/ui/playwright.config.ts b/packages/ui/playwright.config.ts index 33676ab55..3adfaa5ca 100644 --- a/packages/ui/playwright.config.ts +++ b/packages/ui/playwright.config.ts @@ -1,3 +1,4 @@ +/* eslint-disable n/no-process-env */ import { defineConfig, devices } from '@playwright/test' /** diff --git a/packages/ui/scripts/check-completeness.ts b/packages/ui/scripts/check-completeness.ts index 02dd4f509..d18c16e75 100644 --- a/packages/ui/scripts/check-completeness.ts +++ b/packages/ui/scripts/check-completeness.ts @@ -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 { + 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`) } } diff --git a/packages/ui/src/components/OsButton/OsButton.spec.ts b/packages/ui/src/components/OsButton/OsButton.spec.ts index 3349e8694..603a7cfe5 100644 --- a/packages/ui/src/components/OsButton/OsButton.spec.ts +++ b/packages/ui/src/components/OsButton/OsButton.spec.ts @@ -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: '' }, }) 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: '' }, }) 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: '' }, }) - 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: '' }, attrs: { 'aria-label': 'Add' }, }) + expect(wrapper.classes()).toContain('rounded-full') expect(wrapper.classes()).toContain('p-0') }) @@ -270,6 +301,7 @@ describe('osButton', () => { slots: { icon: '' }, attrs: { 'aria-label': 'Add' }, }) + expect(wrapper.classes()).toContain('w-[36px]') }) @@ -279,6 +311,7 @@ describe('osButton', () => { slots: { icon: '' }, attrs: { 'aria-label': 'Add' }, }) + expect(wrapper.classes()).toContain('w-[26px]') }) @@ -288,6 +321,7 @@ describe('osButton', () => { slots: { icon: '' }, attrs: { 'aria-label': 'Add' }, }) + expect(wrapper.classes()).toContain('w-12') }) @@ -297,6 +331,7 @@ describe('osButton', () => { slots: { icon: '' }, attrs: { 'aria-label': 'Add' }, }) + expect(wrapper.classes()).toContain('w-14') }) @@ -306,6 +341,7 @@ describe('osButton', () => { slots: { icon: '' }, 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: '' }, 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: '' }, }) 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: '' }, 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: '' }, 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() }) }) diff --git a/packages/ui/src/components/OsButton/OsButton.visual.spec.ts b/packages/ui/src/components/OsButton/OsButton.visual.spec.ts index 7044e9b1a..3d25065c3 100644 --- a/packages/ui/src/components/OsButton/OsButton.visual.spec.ts +++ b/packages/ui/src/components/OsButton/OsButton.visual.spec.ts @@ -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) }) }) diff --git a/packages/ui/src/components/OsButton/OsButton.vue b/packages/ui/src/components/OsButton/OsButton.vue index df0daa53c..b00668564 100644 --- a/packages/ui/src/components/OsButton/OsButton.vue +++ b/packages/ui/src/components/OsButton/OsButton.vue @@ -1,7 +1,7 @@