Skip to content

Commit

Permalink
fix(vue): emits events on runtime (#566)
Browse files Browse the repository at this point in the history
* fix(vue): emits events on runtime

Signed-off-by: Alexandre Esteves <[email protected]>

* refactor(vue): runtime type & container definition

Signed-off-by: Alexandre Esteves <[email protected]>

* test(vue): add emits test & example project

Signed-off-by: Alexandre Esteves <[email protected]>

* style: prettier error

Signed-off-by: Alexandre Esteves <[email protected]>

---------

Signed-off-by: Alexandre Esteves <[email protected]>
  • Loading branch information
aesteves60 authored Dec 12, 2024
1 parent a0e894f commit 1af50e3
Show file tree
Hide file tree
Showing 10 changed files with 802 additions and 247 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils';
import { MyComponent } from '../src';
import { expect, it, describe, vi } from 'vitest';

describe('MyComponent', () => {
it('should be rendered by Vue', () => {
Expand Down Expand Up @@ -43,20 +44,19 @@ describe('MyComponent', () => {
expect(wrapper.props().kidsNames).toEqual(['billy', 'jane']);
});

it('on myChange value the bound component attribute should update', () => {
const onMyCustomEvent = jest.fn();
const Component = {
template: `<MyComponent type="text" v-on:myCustomEvent="customEventAction($event)"></MyComponent>`,
components: { MyComponent },
methods: {
customEventAction: onMyCustomEvent,
},
};
const wrapper = mount(Component);
const myComponentEl = wrapper.find('my-component').element as HTMLMyComponentElement;
myComponentEl.dispatchEvent(new CustomEvent('my-custom-event', { detail: 5 }));
it('should get emits', async () => {
const wrapper = mount(MyComponent);
wrapper.vm.$emit('myCustomEvent');
expect(wrapper.emitted()).toHaveProperty('myCustomEvent');
});

expect(onMyCustomEvent).toBeCalledTimes(1);
expect(onMyCustomEvent.mock.calls[0][0].detail).toEqual(5);
it('should not emits on unknown event', async () => {
console.warn = vi.fn();
const wrapper = mount(MyComponent);
wrapper.vm.$emit('notMyCustomEvent');
expect(wrapper.emitted()).toHaveProperty('notMyCustomEvent');
expect(console.warn).toHaveBeenCalledWith(
'[Vue warn]: Component emitted event "notMyCustomEvent" but it is neither declared in the emits option nor as an "onNotMyCustomEvent" prop.'
);
});
});
13 changes: 8 additions & 5 deletions example-project/component-library-vue/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
"dev": "pnpm run build.compile --watch",
"prettier": "pnpm run prettier.base --write",
"prettier.base": "prettier \"./({src,__tests__}/**/*.{ts,tsx,js,jsx})|*.{ts,tsx,js,jsx}\"",
"prettier.dry-run": "pnpm run prettier.base --list-different"
"prettier.dry-run": "pnpm run prettier.base --list-different",
"test": "vitest run"
},
"type": "module",
"exports": {
Expand All @@ -26,12 +27,14 @@
},
"types": "./dist/index.d.ts",
"devDependencies": {
"rimraf": "^6.0.1",
"@vue/test-utils": "^2.4.6",
"npm-run-all2": "^6.2.3",
"typescript": "^5.6.2"
"rimraf": "^6.0.1",
"typescript": "^5.6.2",
"vitest": "^2.1.8"
},
"dependencies": {
"component-library": "workspace:*",
"@stencil/vue-output-target": "workspace:*"
"@stencil/vue-output-target": "workspace:*",
"component-library": "workspace:*"
}
}
32 changes: 32 additions & 0 deletions example-project/component-library-vue/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ export const MyButton = /*@__PURE__*/ globalThis.window ? defineContainer<JSX.My
'type',
'myFocus',
'myBlur'
], [
'myFocus',
'myBlur'
]) : defineStencilSSRComponent({
tagName: 'my-button',
hydrateModule: import('component-library/hydrate'),
Expand Down Expand Up @@ -65,6 +68,11 @@ export const MyCheckbox = /*@__PURE__*/ globalThis.window ? defineContainer<JSX.
'myFocus',
'myBlur',
'myStyle'
], [
'myChange',
'myFocus',
'myBlur',
'myStyle'
],
'checked', 'myChange') : defineStencilSSRComponent({
tagName: 'my-checkbox',
Expand Down Expand Up @@ -92,6 +100,8 @@ export const MyComponent = /*@__PURE__*/ globalThis.window ? defineContainer<JSX
'kidsNames',
'favoriteKidName',
'myCustomEvent'
], [
'myCustomEvent'
]) : defineStencilSSRComponent({
tagName: 'my-component',
hydrateModule: import('component-library/hydrate'),
Expand Down Expand Up @@ -137,6 +147,11 @@ export const MyInput = /*@__PURE__*/ globalThis.window ? defineContainer<JSX.MyI
'myChange',
'myBlur',
'myFocus'
], [
'myInput',
'myChange',
'myBlur',
'myFocus'
],
'value', 'myChange') : defineStencilSSRComponent({
tagName: 'my-input',
Expand Down Expand Up @@ -189,6 +204,11 @@ export const MyPopover = /*@__PURE__*/ globalThis.window ? defineContainer<JSX.M
'myPopoverWillPresent',
'myPopoverWillDismiss',
'myPopoverDidDismiss'
], [
'myPopoverDidPresent',
'myPopoverWillPresent',
'myPopoverWillDismiss',
'myPopoverDidDismiss'
]) : defineStencilSSRComponent({
tagName: 'my-popover',
hydrateModule: import('component-library/hydrate'),
Expand Down Expand Up @@ -217,6 +237,11 @@ export const MyRadio = /*@__PURE__*/ globalThis.window ? defineContainer<JSX.MyR
'myFocus',
'myBlur',
'mySelect'
], [
'myStyle',
'myFocus',
'myBlur',
'mySelect'
]) : defineStencilSSRComponent({
tagName: 'my-radio',
hydrateModule: import('component-library/hydrate'),
Expand All @@ -237,6 +262,8 @@ export const MyRadioGroup = /*@__PURE__*/ globalThis.window ? defineContainer<JS
'name',
'value',
'myChange'
], [
'myChange'
],
'value', 'myChange') : defineStencilSSRComponent({
tagName: 'my-radio-group',
Expand Down Expand Up @@ -266,6 +293,11 @@ export const MyRange = /*@__PURE__*/ globalThis.window ? defineContainer<JSX.MyR
'myStyle',
'myFocus',
'myBlur'
], [
'myChange',
'myStyle',
'myFocus',
'myBlur'
],
'value', 'myChange') : defineStencilSSRComponent({
tagName: 'my-range',
Expand Down
8 changes: 8 additions & 0 deletions example-project/component-library-vue/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
},
});
2 changes: 1 addition & 1 deletion example-project/nuxt-app/test/specs/test.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, $, browser } from '@wdio/globals';

describe('Stencil NextJS Integration', () => {
describe('Stencil NuxtJS Integration', () => {
it('should have hydrated the page', async () => {
await browser.url('/');
const input = await $('input[name="my-input-0"]');
Expand Down
2 changes: 1 addition & 1 deletion example-project/vue-app/tests/test.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
/// <reference types="@wdio/mocha-framework" />
import { expect, $, $$, browser } from '@wdio/globals';

describe('Stencil NextJS Integration', () => {
describe('Stencil Vue Integration', () => {
before(() => browser.url('/'));

it('should allow to interact with input element', async () => {
Expand Down
6 changes: 6 additions & 0 deletions packages/vue/src/generate-vue-component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ export const MyComponent = /*@__PURE__*/ defineContainer<Components.MyComponent>
export const MyComponent = /*@__PURE__*/ defineContainer<Components.MyComponent, Components.MyComponent["value"]>('my-component', undefined, [
'value',
'ionChange'
], [
'ionChange'
],
'value', 'ionChange');
`);
Expand Down Expand Up @@ -148,6 +150,8 @@ export const MyComponent = /*@__PURE__*/ defineContainer<Components.MyComponent,
export const MyComponent = /*@__PURE__*/ defineContainer<Components.MyComponent, Components.MyComponent["value"]>('my-component', undefined, [
'value',
'ionChange'
], [
'ionChange'
],
'value', 'ionChange');
`);
Expand Down Expand Up @@ -183,6 +187,8 @@ export const MyComponent = /*@__PURE__*/ defineContainer<Components.MyComponent,
expect(output).toEqual(`
export const MyComponent = /*@__PURE__*/ defineContainer<Components.MyComponent>('my-component', undefined, [
'my-event'
], [
'my-event'
]);
`);
});
Expand Down
25 changes: 24 additions & 1 deletion packages/vue/src/generate-vue-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ export const createComponentDefinition =
const importAs = outputTarget.includeImportCustomElements ? 'define' + tagNameAsPascal : 'undefined';

let props: string[] = [];
let emits: string[] = [];
let propMap: Record<string, [string, string | undefined]> = {};

if (Array.isArray(cmpMeta.properties) && cmpMeta.properties.length > 0) {
props = cmpMeta.properties.map((prop) => `'${prop.name}'`);

Expand All @@ -21,7 +23,9 @@ export const createComponentDefinition =
}

if (Array.isArray(cmpMeta.events) && cmpMeta.events.length > 0) {
props = [...props, ...cmpMeta.events.map((event) => `'${event.name}'`)];
const events = (emits = cmpMeta.events.map((event) => `'${event.name}'`));
props = [...props, ...events];
emits = events;

cmpMeta.events.forEach((event) => {
const handlerName = `on${event.name[0].toUpperCase() + event.name.slice(1)}`;
Expand Down Expand Up @@ -64,6 +68,25 @@ export const ${tagNameAsPascal} = /*@__PURE__*/${ssrTernary}defineContainer<${co
* as there must be a prop for v-model to update,
* but this check is there so builds do not crash.
*/
} else if (emits.length > 0) {
templateString += `, []`;
}

if (emits.length > 0) {
templateString += `, [
${emits.length > 0 ? emits.join(',\n ') : ''}
]`;
/**
* If there are no Emits,
* but v-model is still used,
* make sure we pass in an empty array
* otherwise all of the defineContainer properties
* will be off by one space.
* Note: If you are using v-model then
* the props array should never be empty
* as there must be a prop for v-model to update,
* but this check is there so builds do not crash.
*/
} else if (findModel) {
templateString += `, []`;
}
Expand Down
53 changes: 37 additions & 16 deletions packages/vue/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { defineComponent, getCurrentInstance, h, inject, ref, Ref, withDirective
export { defineStencilSSRComponent } from './ssr';
export interface InputProps<T> {
modelValue?: T;
routerLink?: Symbol;
}

const UPDATE_VALUE_EVENT = 'update:modelValue';
Expand Down Expand Up @@ -49,15 +50,19 @@ const getElementClasses = (
* @prop componentProps - An array of properties on the
* component. These usually match up with the @Prop definitions
* in each component's TSX file.
* @prop emitProps - An array of for event listener on the Component.
* these usually match up with the @Event definitions
* in each compont's TSX file.
* @prop customElement - An option custom element instance to pass
* to customElements.define. Only set if `includeImportCustomElements: true` in your config.
* @prop modelProp - The prop that v-model binds to (i.e. value)
* @prop modelUpdateEvent - The event that is fired from your Web Component when the value changes (i.e. ionChange)
*/
export const defineContainer = <Props, VModelType = string | number | boolean>(
name: string,
defineCustomElement: any,
defineCustomElement: () => void,
componentProps: string[] = [],
emitProps: string[] = [],
modelProp?: string,
modelUpdateEvent?: string
) => {
Expand Down Expand Up @@ -104,14 +109,23 @@ export const defineContainer = <Props, VModelType = string | number | boolean>(
}
});
});

/**
* we register the event emmiter for @Event definitions
* so we can use @event
*/
emitProps.forEach((eventName: string) => {
el.addEventListener(eventName, (e: Event) => {
emit(eventName, e);
});
});
},
};

const currentInstance = getCurrentInstance();
const hasRouter = currentInstance?.appContext?.provides[NAV_MANAGER];
const navManager: NavManager | undefined = hasRouter ? inject(NAV_MANAGER) : undefined;
const handleRouterLink = (ev: Event) => {
// @ts-expect-error
const { routerLink } = props;
if (routerLink === EMPTY_PROP) return;

Expand Down Expand Up @@ -227,25 +241,32 @@ export const defineContainer = <Props, VModelType = string | number | boolean>(
});

if (typeof Container !== 'function') {
// @ts-expect-error
Container.name = name;
let emits: string[] = [];
let props: Record<string, unknown> = {};

// @ts-expect-error
Container.props = {
[ROUTER_LINK_VALUE]: DEFAULT_EMPTY_PROP,
};
props.ROUTER_LINK_VALUE = DEFAULT_EMPTY_PROP;

componentProps.forEach((componentProp) => {
// @ts-expect-error
Container.props[componentProp] = DEFAULT_EMPTY_PROP;
});
componentProps.forEach((componentProp) => (props[componentProp] = DEFAULT_EMPTY_PROP));

emits = emitProps;

if (modelProp) {
// @ts-expect-error
Container.props[MODEL_VALUE] = DEFAULT_EMPTY_PROP;
// @ts-expect-error
Container.emits = [UPDATE_VALUE_EVENT];
props.MODEL_VALUE = DEFAULT_EMPTY_PROP;
emits.push(UPDATE_VALUE_EVENT);
}

/**
* Add emit props to the component.
* This is necessary for Vue to know
* which events to listen to.
* @see https://v3.vuejs.org/guide/component-custom-events.html#event-names
*/
// @ts-expect-error
Container.name = name;
// @ts-expect-error
Container.emits = emits;
// @ts-expect-error
Container.props = props;
}

return Container;
Expand Down
Loading

0 comments on commit 1af50e3

Please sign in to comment.