diff --git a/README.md b/README.md index 9a76436cf..294ec8eff 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Enter the component you want most in the components, leave the emojis and follow ### Components ### Components -| Component | Status | 🔗 Hook v1 | 👀 Visual Check | 📄 Docs | 📝 Note | +| Component | Status | 🔗 Hook v1 | 👀 Visual Check | 📄 Test | 📝 Note | | ------------------------------------------------------------------------------------------------ | ------------ | ---------- | --------------- | ------- | ------------------------------ | | [Accordion](https://vue-primitives.netlify.app/?path=/story/components-accordion--single) | ✅ Completed | ✅ | ✅ | | | | [AlertDialog](https://vue-primitives.netlify.app/?path=/story/components-alertdialog--styled) | ✅ Completed | ✅ | | | | @@ -50,7 +50,7 @@ Enter the component you want most in the components, leave the emojis and follow | [DropdownMenu](https://vue-primitives.netlify.app/?path=/story/components-dropdownmenu--styled) | ✅ Completed | ✅ | | | | | Form | ❌ Not Started | ❌ | | | | | [HoverCard](https://vue-primitives.netlify.app/?path=/story/components-hovercard--chromatic) | ✅ Completed | ✅ | | | 🔧 Needs polygon; fix close | -| [Label](https://vue-primitives.netlify.app/?path=/story/components-label--styled) | ✅ Completed | ✅ | | | | +| [Label](https://vue-primitives.netlify.app/?path=/story/components-label--styled) | ✅ Completed | ✅ | | ✅ | | | [Menubar](https://vue-primitives.netlify.app/?path=/story/components-menubar--styled) | ✅ Completed | ✅ | | | | | NavigationMenu | 🚧 In Progress | 🚧 | | | | | [Popover](https://vue-primitives.netlify.app/?path=/story/components-popover--styled) | ✅ Completed | ✅ | | | | diff --git a/packages/core/package.json b/packages/core/package.json index 7cddf0d67..84db4c5a0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -92,6 +92,9 @@ "build-storybook": "storybook build", "eslint": "eslint .", "eslint:fix": "eslint . --fix", + "test": "vitest run", + "test:watch": "vitest --watch", + "test:nuxt": "vitest -c vitest.nuxt.config.ts --coverage", "release": "pnpm build && pnpm publish --no-git-checks --access public", "release:beta": "pnpm release --tag beta --access public", "release:alpha": "pnpm release --tag alpha --access public", @@ -107,6 +110,7 @@ "aria-hidden": "^1.2.4" }, "devDependencies": { + "@testing-library/vue": "^8.1.0", "@tsconfig/node20": "^20.1.4", "@types/jsdom": "^21.1.7", "@types/node": "^22.9.0", @@ -126,7 +130,9 @@ "vite-plugin-externalize-deps": "^0.8.0", "vite-plugin-pages": "^0.32.3", "vite-tsconfig-paths": "^5.1.3", - "vitest": "^2.1.4", + "vitest": "link:@testing-library/jest-dom/vitest", + "vitest-axe": "1.0.0-pre.3", + "vitest-canvas-mock": "^0.3.3", "vue": "^3.5.12", "vue-router": "^4.4.5", "vue-tsc": "^2.1.10" diff --git a/packages/core/src/label/__tests__/Label.test.ts b/packages/core/src/label/__tests__/Label.test.ts new file mode 100644 index 000000000..d06738fa9 --- /dev/null +++ b/packages/core/src/label/__tests__/Label.test.ts @@ -0,0 +1,273 @@ +import { render, screen } from '@testing-library/vue' +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import { axe } from 'vitest-axe' +import { Label } from '../index' +import { useLabel } from '../Label' + +describe('label', () => { + describe('component', () => { + it('should render correctly', () => { + const wrapper = mount(Label) + expect(wrapper.element.tagName).toBe('LABEL') + }) + + it('should render with custom tag', () => { + const wrapper = mount(Label, { + props: { + as: 'div', + }, + }) + expect(wrapper.element.tagName).toBe('DIV') + }) + + it('should pass through attrs', () => { + const wrapper = mount(Label, { + attrs: { + 'id': 'test-label', + 'data-testid': 'label', + }, + }) + expect(wrapper.attributes('id')).toBe('test-label') + expect(wrapper.attributes('data-testid')).toBe('label') + }) + + it('should emit mousedown event', async () => { + const wrapper = mount(Label) + await wrapper.trigger('mousedown') + expect(wrapper.emitted('mousedown')).toBeTruthy() + }) + + it('should handle slot content updates', async () => { + const wrapper = mount({ + components: { Label }, + data() { + return { + content: 'Initial Label', + } + }, + template: ``, + }) + expect(wrapper.text()).toBe('Initial Label') + await wrapper.setData({ content: 'Updated Label' }) + expect(wrapper.text()).toBe('Updated Label') + }) + + it('should handle dynamic class changes', async () => { + const wrapper = mount(Label, { + attrs: { + class: 'initial-class', + }, + }) + expect(wrapper.classes()).toContain('initial-class') + await wrapper.setProps({ class: 'updated-class' }) + expect(wrapper.classes()).toContain('updated-class') + }) + + it('should handle multiple mousedown events', async () => { + const wrapper = mount(Label) + await wrapper.trigger('mousedown') + await wrapper.trigger('mousedown') + expect(wrapper.emitted('mousedown')).toHaveLength(2) + }) + }) + + describe('useLabel', () => { + it('should prevent text selection on double click', () => { + const mockProps = { + onMousedown: vi.fn(), + } + const { attrs } = useLabel(mockProps) + const div = document.createElement('div') + const event = new MouseEvent('mousedown', { + detail: 2, + bubbles: true, + cancelable: true, + }) + Object.defineProperty(event, 'target', { value: div }) + event.preventDefault = vi.fn() + + const resolvedAttrs = attrs([]) + if (resolvedAttrs.onMousedown) { + resolvedAttrs.onMousedown(event) + } + + expect(event.preventDefault).toHaveBeenCalled() + expect(mockProps.onMousedown).toHaveBeenCalledWith(event) + }) + + it('should not prevent mousedown on form controls', () => { + const mockProps = { + onMousedown: vi.fn(), + } + const { attrs } = useLabel(mockProps) + const button = document.createElement('button') + const event = new MouseEvent('mousedown') + Object.defineProperty(event, 'target', { value: button }) + + const resolvedAttrs = attrs([]) + if (resolvedAttrs.onMousedown) { + resolvedAttrs.onMousedown(event) + } + + expect(mockProps.onMousedown).not.toHaveBeenCalled() + }) + + it('should merge extra attrs', () => { + const { attrs } = useLabel() + const extraAttrs = [{ + class: 'test-class', + id: 'test-id', + }] + + const resolvedAttrs = attrs(extraAttrs) + + expect(resolvedAttrs.class).toBe('test-class') + expect(resolvedAttrs.id).toBe('test-id') + expect(resolvedAttrs.onMousedown).toBeDefined() + }) + }) + + describe('form control interactions', () => { + it('should trigger associated input focus on click', async () => { + // Mount using @testing-library/vue + render({ + components: { Label }, + template: ` +
+ + +
+ `, + }) + + const input = screen.getByTestId('input') + const label = screen.getByText('Click me') + + // Mock focus method + const focusSpy = vi.spyOn(input, 'focus') + + // Simulate the native behavior + label.click() + input.focus() + + expect(focusSpy).toHaveBeenCalled() + focusSpy.mockRestore() + }) + + it('should work with nested form controls', async () => { + const wrapper = mount(Label, { + slots: { + default: '', + }, + }) + + const checkbox = wrapper.find('input') + await wrapper.trigger('click') + expect(checkbox.element.checked).toBe(true) + }) + }) + + describe('accessibility', () => { + it('should have no accessibility violations', async () => { + const wrapper = mount(Label, { + slots: { + default: 'Test Label', + }, + }) + + const results = await axe(wrapper.element) + expect(results).toHaveNoViolations() + }) + + it('should have no accessibility violations with custom tag', async () => { + const wrapper = mount(Label, { + props: { + as: 'div', + }, + slots: { + default: 'Test Label', + }, + }) + + const results = await axe(wrapper.element) + expect(results).toHaveNoViolations() + }) + + it('should have no violations when associated with form control', async () => { + const wrapper = mount({ + template: ` +
+ + +
+ `, + components: { Label }, + }) + + const results = await axe(wrapper.element) + expect(results).toHaveNoViolations() + }) + + it('should maintain accessibility when content changes dynamically', async () => { + const wrapper = mount({ + components: { Label }, + data() { + return { + content: 'Initial Label', + } + }, + template: ` + + `, + }) + + expect(wrapper.text()).toBe('Initial Label') + await wrapper.setData({ content: 'Updated Label' }) + const results = await axe(wrapper.element) + expect(results).toHaveNoViolations() + }) + + it('should be keyboard navigable', () => { + const wrapper = mount(Label, { + attrs: { + tabindex: '0', + }, + }) + expect(wrapper.attributes('tabindex')).toBe('0') + }) + + it('should support aria-labelledby', async () => { + const wrapper = mount({ + template: ` +
+ +
Labeled content
+
+ `, + components: { Label }, + }) + + const results = await axe(wrapper.element) + expect(results).toHaveNoViolations() + }) + }) + + describe('edge cases', () => { + it('should handle empty slots gracefully', () => { + const wrapper = mount(Label) + expect(wrapper.text()).toBe('') + expect(() => wrapper.trigger('mousedown')).not.toThrow() + }) + + it('should handle malformed for attribute', async () => { + const wrapper = mount(Label, { + props: { + for: 'non-existent-id', + }, + }) + const results = await axe(wrapper.element) + expect(results).toHaveNoViolations() + }) + }) +}) diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 530622585..cfdfd870a 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -1,14 +1,41 @@ +import { resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { configDefaults, defineConfig, mergeConfig } from 'vitest/config' -import viteConfig from './vite.config.ts' +import vue from '@vitejs/plugin-vue' +import { defineConfig } from 'vitest/config' -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'jsdom', - exclude: [...configDefaults.exclude, 'e2e/**'], - root: fileURLToPath(new URL('./', import.meta.url)), - }, - }), -) +const r = (p: string) => resolve(__dirname, p) + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': r('./src'), + }, + dedupe: [ + 'vue', + '@vue/runtime-core', + ], + }, + test: { + environment: 'jsdom', + globals: true, + exclude: ['**/node_modules/**'], + include: ['./**/*.test.{ts,js}'], + coverage: { + provider: 'istanbul', // or 'v8' + }, + root: fileURLToPath(new URL('./', import.meta.url)), + globalSetup: './vitest.global.ts', + setupFiles: './vitest.setup.ts', + server: { + deps: { + inline: ['vitest-canvas-mock'], + }, + }, + environmentOptions: { + jsdom: { + resources: 'usable', + }, + }, + }, +}) diff --git a/packages/core/vitest.global.ts b/packages/core/vitest.global.ts new file mode 100644 index 000000000..df5734bb7 --- /dev/null +++ b/packages/core/vitest.global.ts @@ -0,0 +1,3 @@ +export function setup() { + process.env.TZ = 'US/Eastern' +} diff --git a/packages/core/vitest.setup.ts b/packages/core/vitest.setup.ts new file mode 100644 index 000000000..6391fb07a --- /dev/null +++ b/packages/core/vitest.setup.ts @@ -0,0 +1,28 @@ +import { beforeAll, expect, vi } from 'vitest' +import { configureAxe } from 'vitest-axe' +import * as matchers from 'vitest-axe/matchers' +import '@testing-library/jest-dom/vitest' +import 'vitest-canvas-mock' + +// Update type augmentation to use the correct matcher type +declare module 'vitest' { + interface Assertion { + // eslint-disable-next-line ts/method-signature-style + toHaveNoViolations(): T + } +} + +expect.extend(matchers) + +configureAxe({ + globalOptions: { + rules: [{ + id: 'region', + enabled: false, + }], + }, +}) + +beforeAll(() => { + window.HTMLElement.prototype.scrollIntoView = vi.fn() +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40a3d7935..42b730623 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -256,6 +256,9 @@ importers: specifier: ^1.2.4 version: 1.2.4 devDependencies: + '@testing-library/vue': + specifier: ^8.1.0 + version: 8.1.0(@vue/compiler-sfc@3.5.13)(vue@3.5.13(typescript@5.6.3)) '@tsconfig/node20': specifier: ^20.1.4 version: 20.1.4 @@ -314,8 +317,14 @@ importers: specifier: ^5.1.3 version: 5.1.3(typescript@5.6.3)(vite@5.4.11(@types/node@22.9.0)(terser@5.36.0)) vitest: - specifier: ^2.1.4 - version: 2.1.5(@types/node@22.9.0)(happy-dom@15.11.6)(jsdom@25.0.1)(terser@5.36.0) + specifier: link:@testing-library/jest-dom/vitest + version: link:@testing-library/jest-dom/vitest + vitest-axe: + specifier: 1.0.0-pre.3 + version: 1.0.0-pre.3(vitest@packages+core+@testing-library+jest-dom+vitest) + vitest-canvas-mock: + specifier: ^0.3.3 + version: 0.3.3(vitest@packages+core+@testing-library+jest-dom+vitest) vue: specifier: ^3.5.12 version: 3.5.13(typescript@5.6.3) @@ -2240,6 +2249,16 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' + '@testing-library/vue@8.1.0': + resolution: {integrity: sha512-ls4RiHO1ta4mxqqajWRh8158uFObVrrtAPoxk7cIp4HrnQUj/ScKzqz53HxYpG3X6Zb7H2v+0eTGLSoy8HQ2nA==} + engines: {node: '>=14'} + peerDependencies: + '@vue/compiler-sfc': '>= 3' + vue: '>= 3' + peerDependenciesMeta: + '@vue/compiler-sfc': + optional: true + '@trysound/sax@0.2.0': resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} @@ -2634,8 +2653,8 @@ packages: '@vue/tsconfig@0.6.0': resolution: {integrity: sha512-MHXNd6lzugsEHvuA6l1GqrF5jROqUon8sP/HInLPnthJiYvB0VvpHMywg7em1dBZfFZNBSkR68qH37zOdRHmCw==} peerDependencies: - typescript: 5.x - vue: ^3.3.0 + typescript: 5.7.2 + vue: 3.5.13 peerDependenciesMeta: typescript: optional: true @@ -3457,6 +3476,9 @@ packages: engines: {node: '>=4'} hasBin: true + cssfontparser@1.2.1: + resolution: {integrity: sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==} + cssnano-preset-default@7.0.6: resolution: {integrity: sha512-ZzrgYupYxEvdGGuqL+JKOY70s7+saoNlHSCK/OGn1vB2pQK8KSET8jvenzItcY+kA7NoWvfbb/YhlzuzNKjOhQ==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} @@ -4988,6 +5010,9 @@ packages: resolution: {integrity: sha512-bZsjR/iRjl1Nk1UkjGpAzLNfQtzuijhn2g+pbZb98HQ1Gk8vM9hfbxeMBP+M2/UUdwj0RqGG3mlvk2MsAqwvEw==} engines: {node: 20 || >=22} + jest-canvas-mock@2.5.2: + resolution: {integrity: sha512-vgnpPupjOL6+L5oJXzxTxFrlGEIbHdZqFU+LFNdtLxZ3lRDCl17FlTMM7IatoRQkrcyOTMlDinjUguqmQ6bR2A==} + jest-diff@29.7.0: resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -5591,6 +5616,9 @@ packages: mlly@1.7.2: resolution: {integrity: sha512-tN3dvVHYVz4DhSXinXIk7u9syPYaJvio118uomkovAtWBT+RdbP6Lfh/5Lvo519YMmwBafwlh20IPTXIStscpA==} + moo-color@1.0.3: + resolution: {integrity: sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==} + motion@11.11.17: resolution: {integrity: sha512-y6mXYElvJ5HHwPBUpYG/5wclKVGW4hJhqPkTjWccib5/WrcRM185adg3+4aSmG5iD10XKFt5uBOAiKwuzMHPPQ==} peerDependencies: @@ -7768,6 +7796,11 @@ packages: peerDependencies: vitest: '>=0.31.0' + vitest-canvas-mock@0.3.3: + resolution: {integrity: sha512-3P968tYBpqYyzzOaVtqnmYjqbe13576/fkjbDEJSfQAkHtC5/UjuRHOhFEN/ZV5HVZIkaROBUWgazDKJ+Ibw+Q==} + peerDependencies: + vitest: '*' + vitest@2.1.5: resolution: {integrity: sha512-P4ljsdpuzRTPI/kbND2sDZ4VmieerR2c9szEZpjc+98Z9ebvnXmM5+0tHEKqYZumXqlvnmfWsjeFOjXVriDG7A==} engines: {node: ^18.0.0 || >=20.0.0} @@ -10073,6 +10106,15 @@ snapshots: dependencies: '@testing-library/dom': 9.3.4 + '@testing-library/vue@8.1.0(@vue/compiler-sfc@3.5.13)(vue@3.5.13(typescript@5.6.3))': + dependencies: + '@babel/runtime': 7.26.0 + '@testing-library/dom': 9.3.4 + '@vue/test-utils': 2.4.6 + vue: 3.5.13(typescript@5.6.3) + optionalDependencies: + '@vue/compiler-sfc': 3.5.13 + '@trysound/sax@0.2.0': {} '@tsconfig/node20@20.1.4': {} @@ -11494,6 +11536,8 @@ snapshots: cssesc@3.0.0: {} + cssfontparser@1.2.1: {} + cssnano-preset-default@7.0.6(postcss@8.4.49): dependencies: browserslist: 4.24.2 @@ -13197,6 +13241,11 @@ snapshots: dependencies: '@isaacs/cliui': 8.0.2 + jest-canvas-mock@2.5.2: + dependencies: + cssfontparser: 1.2.1 + moo-color: 1.0.3 + jest-diff@29.7.0: dependencies: chalk: 4.1.2 @@ -14003,6 +14052,10 @@ snapshots: pkg-types: 1.2.1 ufo: 1.5.4 + moo-color@1.0.3: + dependencies: + color-name: 1.1.4 + motion@11.11.17(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: framer-motion: 11.11.17(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -16610,6 +16663,18 @@ snapshots: lodash-es: 4.17.21 vitest: 2.1.5(@types/node@22.9.0)(happy-dom@15.11.6)(jsdom@25.0.1)(terser@5.36.0) + vitest-axe@1.0.0-pre.3(vitest@packages+core+@testing-library+jest-dom+vitest): + dependencies: + axe-core: 4.10.2 + chalk: 5.3.0 + lodash-es: 4.17.21 + vitest: link:packages/core/@testing-library/jest-dom/vitest + + vitest-canvas-mock@0.3.3(vitest@packages+core+@testing-library+jest-dom+vitest): + dependencies: + jest-canvas-mock: 2.5.2 + vitest: link:packages/core/@testing-library/jest-dom/vitest + vitest@2.1.5(@types/node@22.9.0)(happy-dom@15.11.6)(jsdom@25.0.1)(terser@5.36.0): dependencies: '@vitest/expect': 2.1.5