diff --git a/.changeset/hip-cougars-reply.md b/.changeset/hip-cougars-reply.md new file mode 100644 index 00000000..9dac8492 --- /dev/null +++ b/.changeset/hip-cougars-reply.md @@ -0,0 +1,5 @@ +--- +'@polymorphic-factory/vue': minor +--- + +Polymorphic Vue components now support the v-model directive. diff --git a/packages/vue/package.json b/packages/vue/package.json index bb66e508..738c4348 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -35,6 +35,7 @@ "@testing-library/vue": "6.6.1", "@types/jsdom": "20.0.1", "@types/testing-library__jest-dom": "5.14.5", + "@vitejs/plugin-vue": "4.0.0", "@vitejs/plugin-vue-jsx": "3.0.0", "@vitest/coverage-c8": "0.26.0", "clean-package": "2.2.0", diff --git a/packages/vue/src/@types/vue-shim.d.ts b/packages/vue/src/@types/vue-shim.d.ts new file mode 100644 index 00000000..d9f24faa --- /dev/null +++ b/packages/vue/src/@types/vue-shim.d.ts @@ -0,0 +1,4 @@ +declare module '*.vue' { + import Vue from 'vue' + export default Vue +} diff --git a/packages/vue/src/polymorphic-factory.tsx b/packages/vue/src/polymorphic-factory.tsx index cce7b842..7a6921ad 100644 --- a/packages/vue/src/polymorphic-factory.tsx +++ b/packages/vue/src/polymorphic-factory.tsx @@ -2,11 +2,14 @@ import { type AllowedComponentProps, type ComponentCustomProps, type DefineComponent, + h, defineComponent, type ExtractPropTypes, type VNodeProps, + computed, } from 'vue' import type { IntrinsicElementAttributes } from './dom.types' +import { useVModel } from './use-v-model' export type DOMElements = keyof IntrinsicElementAttributes @@ -51,10 +54,19 @@ type PolymorphFactory = { function defaultStyled(originalComponent: ElementType) { return defineComponent({ - props: ['as'], - setup(props, { slots, attrs }) { + props: ['as', 'modelValue'], + emits: ['update:modelValue', 'input', 'change'], + setup(props, { slots, attrs, emit }) { const Component = props.as || originalComponent - return () => {slots} + const vmodelAttrs = computed(() => + useVModel(Component as string, props.modelValue, emit, attrs), + ) + + return () => ( + + {slots?.default?.()} + + ) }, }) as ComponentWithAs } diff --git a/packages/vue/src/use-v-model.ts b/packages/vue/src/use-v-model.ts new file mode 100644 index 00000000..607d680c --- /dev/null +++ b/packages/vue/src/use-v-model.ts @@ -0,0 +1,57 @@ +import { computed } from 'vue' + +const formElements = ['input', 'select', 'textarea', 'fieldset', 'datalist', 'option', 'optgroup'] + +export const useVModel = ( + elementTag: string, + modelValue: any, + emit: CallableFunction, + attrs: any, +) => { + let vmodelAttrs = {} + const handleMultipleCheckbox = (value: any) => { + const currentModelValue = [...modelValue] + if (currentModelValue.includes(value)) { + currentModelValue.splice(currentModelValue.indexOf(value), 1) + return currentModelValue + } else { + return [...currentModelValue, value] + } + } + const handleInput = (e: Event) => { + emit( + 'update:modelValue', + attrs.type === 'checkbox' && Array.isArray(modelValue) + ? handleMultipleCheckbox((e?.currentTarget as HTMLInputElement)?.value) + : typeof modelValue === 'boolean' + ? (e?.currentTarget as HTMLInputElement)?.checked + : (e?.currentTarget as HTMLInputElement)?.value, + ) + emit('input', e, (e?.currentTarget as HTMLInputElement)?.value) + emit( + 'change', + e, + typeof modelValue === 'boolean' + ? (e?.currentTarget as HTMLInputElement)?.checked + : (e?.currentTarget as HTMLInputElement)?.value, + ) + } + + if (formElements.includes(elementTag)) { + let val: Record = { value: modelValue } + if (elementTag === 'input' && (attrs.type === 'checkbox' || attrs.type === 'radio')) { + const isChecked = computed(() => + typeof modelValue === 'boolean' ? modelValue : modelValue.includes(attrs.value), + ) + val = { + checked: isChecked.value, + } + } + vmodelAttrs = { + ...val, + onInput: handleInput, + } + } + + return vmodelAttrs +} diff --git a/packages/vue/test/poly-test.vue b/packages/vue/test/poly-test.vue new file mode 100644 index 00000000..2d6a55c7 --- /dev/null +++ b/packages/vue/test/poly-test.vue @@ -0,0 +1,40 @@ + + diff --git a/packages/vue/test/sfc.test.ts b/packages/vue/test/sfc.test.ts new file mode 100644 index 00000000..d2570efb --- /dev/null +++ b/packages/vue/test/sfc.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { screen, render, fireEvent } from '@testing-library/vue' +import PolyTest from './poly-test.vue' + +describe('SFC related features', () => { + it('has default value with v-model', () => { + render(PolyTest) + + const inputElement: HTMLInputElement = screen.getByTestId('poly-input') + const divElement: any = screen.getByTestId('poly-div') + + expect(inputElement.value).toBe('test') + // Div element should not have "value" bound to them + expect(divElement.value).toBeUndefined() + expect(divElement.modelvalue).toBeUndefined() + }) + + it('updates input with vmodel correctly', async () => { + render(PolyTest) + + const inputElement: HTMLInputElement = screen.getByTestId('poly-input') + + await fireEvent.update(inputElement, 'Done') + + expect(inputElement.value).toBe('Done') + }) + + it('updates select correctly', async () => { + render(PolyTest) + + const select = screen.getByTestId('poly-select') + + await fireEvent.change(select, { target: { value: 2 } }) + + const options: HTMLOptionElement[] = screen.getAllByRole("option") + + expect(options[0].selected).toBeFalsy() + expect(options[1].selected).toBeTruthy() + expect(options[2].selected).toBeFalsy() + }) + + it('updates radio correctly', async () => { + render(PolyTest) + + const radio = screen.getByTestId('react-radio') + + await fireEvent.click(radio) + + screen.getByText("Radio: react") + }) + + it("updates multiple checkbox selection", async () => { + render(PolyTest) + + const john = screen.getByTestId("checkbox-john") + const jack = screen.getByTestId("checkbox-jack") + + await fireEvent.click(john) + + screen.getByText("checked : John") + + await fireEvent.click(jack) + + screen.getByText("checked : John,Jack") + }) + + it("updates single checkbox with boolean", async () => { + render(PolyTest) + + const singleCheckbox = screen.getByTestId("single-checkbox") + + await fireEvent.click(singleCheckbox) + + screen.getByText("single checked : false") + + await fireEvent.click(singleCheckbox) + + screen.getByText("single checked : true") + }) +}) diff --git a/packages/vue/vite.config.ts b/packages/vue/vite.config.ts index 399cbfd5..66d95d44 100644 --- a/packages/vue/vite.config.ts +++ b/packages/vue/vite.config.ts @@ -2,6 +2,7 @@ /// import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' import vueJsx from '@vitejs/plugin-vue-jsx' export default defineConfig({ @@ -14,5 +15,5 @@ export default defineConfig({ web: [/\.[jt]sx$/], }, }, - plugins: [vueJsx()], + plugins: [vue(), vueJsx()], }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2e5c043..e9f96998 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,6 +134,7 @@ importers: '@testing-library/vue': 6.6.1 '@types/jsdom': 20.0.1 '@types/testing-library__jest-dom': 5.14.5 + '@vitejs/plugin-vue': 4.0.0 '@vitejs/plugin-vue-jsx': 3.0.0 '@vitest/coverage-c8': 0.26.0 clean-package: 2.2.0 @@ -148,6 +149,7 @@ importers: '@testing-library/vue': 6.6.1_vue@3.2.45 '@types/jsdom': 20.0.1 '@types/testing-library__jest-dom': 5.14.5 + '@vitejs/plugin-vue': 4.0.0_vite@4.0.2+vue@3.2.45 '@vitejs/plugin-vue-jsx': 3.0.0_vite@4.0.2+vue@3.2.45 '@vitest/coverage-c8': 0.26.0_jsdom@20.0.3 clean-package: 2.2.0 @@ -2053,6 +2055,17 @@ packages: - supports-color dev: true + /@vitejs/plugin-vue/4.0.0_vite@4.0.2+vue@3.2.45: + resolution: {integrity: sha512-e0X4jErIxAB5oLtDqbHvHpJe/uWNkdpYV83AOG2xo2tEVSzCzewgJMtREZM30wXnM5ls90hxiOtAuVU6H5JgbA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.0.0 + vue: ^3.2.25 + dependencies: + vite: 4.0.2 + vue: 3.2.45 + dev: true + /@vitest/coverage-c8/0.26.0_jsdom@20.0.3: resolution: {integrity: sha512-1LSMHvX1Winy1dIV1XqQanIskYBvd3+TlQtxO6BeyFa57Lah2uNBm3gh5iDB+ZWCySN5o6bl7qOJdaZjEQZZeg==} dependencies: