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