Skip to content

Commit

Permalink
feat(compiler): generate component custom event types with HTML target (
Browse files Browse the repository at this point in the history
#3296)

with this commit, the compiler will generate new exported
interfaces in `components.d.ts` for all web components that
have custom events. the generated interfaces include the
typings to the generated HTML element type for the event target
and accepts a generic for the custom event detail. the compiler
will generate the web component custom event type with the
new type, allowing usage with the custom event and the generated
HTML element type for the target.
  • Loading branch information
sean-perkins committed May 9, 2022
1 parent 2f8a6c0 commit 846740f
Show file tree
Hide file tree
Showing 14 changed files with 307 additions and 26 deletions.
14 changes: 13 additions & 1 deletion src/compiler/types/generate-app-types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type * as d from '../../declarations';
import { COMPONENTS_DTS_HEADER, sortImportNames } from './types-utils';
import { generateComponentTypes } from './generate-component-types';
import { generateEventDetailTypes } from './generate-event-detail-types';
import { GENERATED_DTS, getComponentsDtsSrcFilePath } from '../output-targets/output-utils';
import { isAbsolute, relative, resolve } from 'path';
import { normalizePath } from '@utils';
Expand Down Expand Up @@ -68,6 +69,7 @@ const generateComponentTypesFile = (config: d.Config, buildCtx: d.BuildCtx, areT
const c: string[] = [];
const allTypes = new Map<string, number>();
const components = buildCtx.components.filter((m) => !m.isCollectionDependency);
const componentEventDetailTypes: d.TypesModule[] = [];

const modules: d.TypesModule[] = components.map((cmp) => {
/**
Expand All @@ -77,6 +79,12 @@ const generateComponentTypesFile = (config: d.Config, buildCtx: d.BuildCtx, areT
* grow as more components (with additional types) are processed.
*/
typeImportData = updateReferenceTypeImports(typeImportData, allTypes, cmp, cmp.sourceFilePath);
if (cmp.events.length > 0) {
/**
* Only generate event detail types for components that have events.
*/
componentEventDetailTypes.push(generateEventDetailTypes(cmp));
}
return generateComponentTypes(cmp, typeImportData, areTypesInternal);
});

Expand Down Expand Up @@ -107,7 +115,11 @@ const generateComponentTypesFile = (config: d.Config, buildCtx: d.BuildCtx, areT
})
);

c.push(`export namespace Components {\n${modules.map((m) => `${m.component}`).join('\n')}\n}`);
c.push(`export namespace Components {`);
c.push(...modules.map((m) => `${m.component}`));
c.push(`}`);

c.push(...componentEventDetailTypes.map((m) => `${m.component}`));

c.push(`declare global {`);

Expand Down
2 changes: 1 addition & 1 deletion src/compiler/types/generate-component-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const generateComponentTypes = (

const propAttributes = generatePropTypes(cmp, typeImportData);
const methodAttributes = generateMethodTypes(cmp, typeImportData);
const eventAttributes = generateEventTypes(cmp, typeImportData);
const eventAttributes = generateEventTypes(cmp, typeImportData, tagNameAsPascal);

const componentAttributes = attributesToMultiLineString(
[...propAttributes, ...methodAttributes],
Expand Down
37 changes: 37 additions & 0 deletions src/compiler/types/generate-event-detail-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type * as d from '../../declarations';
import { dashToPascalCase } from '@utils';

/**
* Generates the custom event interface for each component that combines the `CustomEvent` interface with
* the HTMLElement target. This is used to allow implementers to use strict typings on event handlers.
*
* The generated interface accepts a generic for the event detail type. This allows implementers to use
* custom typings for individual events without Stencil needing to generate an interface for each event.
*
* @param cmp The component compiler metadata
* @returns The generated interface type definition.
*/
export const generateEventDetailTypes = (cmp: d.ComponentCompilerMeta): d.TypesModule => {
const tagName = cmp.tagName.toLowerCase();
const tagNameAsPascal = dashToPascalCase(tagName);
const htmlElementName = `HTML${tagNameAsPascal}Element`;

const isDep = cmp.isCollectionDependency;

const cmpEventInterface = `${tagNameAsPascal}CustomEvent`;
const cmpInterface = [
`export interface ${cmpEventInterface}<T> extends CustomEvent<T> {`,
` detail: T;`,
` target: ${htmlElementName};`,
`}`,
];
return {
isDep,
tagName,
tagNameAsPascal,
htmlElementName,
component: cmpInterface.join('\n'),
jsx: cmpInterface.join('\n'),
element: cmpInterface.join('\n'),
};
};
18 changes: 14 additions & 4 deletions src/compiler/types/generate-event-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,42 @@ import { updateTypeIdentifierNames } from './stencil-types';
* Generates the individual event types for all @Event() decorated events in a component
* @param cmpMeta component runtime metadata for a single component
* @param typeImportData locally/imported/globally used type names, which may be used to prevent naming collisions
* @param cmpClassName The pascal cased name of the component class
* @returns the generated type metadata
*/
export const generateEventTypes = (cmpMeta: d.ComponentCompilerMeta, typeImportData: d.TypesImportData): d.TypeInfo => {
export const generateEventTypes = (
cmpMeta: d.ComponentCompilerMeta,
typeImportData: d.TypesImportData,
cmpClassName: string
): d.TypeInfo => {
return cmpMeta.events.map((cmpEvent) => {
const name = `on${toTitleCase(cmpEvent.name)}`;
const type = getEventType(cmpEvent, typeImportData, cmpMeta.sourceFilePath);
return {
const cmpEventDetailInterface = `${cmpClassName}CustomEvent`;
const type = getEventType(cmpEvent, cmpEventDetailInterface, typeImportData, cmpMeta.sourceFilePath);

const typeInfo: d.TypeInfo[0] = {
name,
type,
optional: false,
required: false,
internal: cmpEvent.internal,
jsdoc: getTextDocs(cmpEvent.docs),
};
return typeInfo;
});
};

/**
* Determine the correct type name for all type(s) used by a class member annotated with `@Event()`
* @param cmpEvent the compiler metadata for a single `@Event()`
* @param cmpEventDetailInterface the name of the custom event type to use in the generated type
* @param typeImportData locally/imported/globally used type names, which may be used to prevent naming collisions
* @param componentSourcePath the path to the component on disk
* @returns the type associated with a `@Event()`
*/
const getEventType = (
cmpEvent: d.ComponentCompilerEvent,
cmpEventDetailInterface: string,
typeImportData: d.TypesImportData,
componentSourcePath: string
): string => {
Expand All @@ -44,5 +54,5 @@ const getEventType = (
componentSourcePath,
cmpEvent.complexType.original
);
return `(event: CustomEvent<${updatedTypeName}>) => void`;
return `(event: ${cmpEventDetailInterface}<${updatedTypeName}>) => void`;
};
2 changes: 2 additions & 0 deletions src/compiler/types/tests/ComponentCompilerMeta.stub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ export const stubComponentCompilerMeta = (
): d.ComponentCompilerMeta => {
// TODO(STENCIL-378): Continue to build out default stub, remove the type assertion on `default`
const defaults: d.ComponentCompilerMeta = {
isCollectionDependency: false,
events: [],
methods: [],
properties: [],
sourceFilePath: '/some/stubbed/path/my-component.tsx',
tagName: 'stub-cmp',
virtualProperties: [],
} as d.ComponentCompilerMeta;

Expand Down
32 changes: 32 additions & 0 deletions src/compiler/types/tests/generate-event-detail-types.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type * as d from '../../../declarations';
import { stubComponentCompilerMeta } from './ComponentCompilerMeta.stub';
import { generateEventDetailTypes } from '../generate-event-detail-types';

describe('generate-event-detail-types', () => {
describe('generateEventDetailTypes', () => {
it('returns the correct type module data for a component', () => {
const tagName = 'event-detail-test-tag';
const tagNameAsPascal = 'EventDetailTestTag';

const expectedTypeInfo = `export interface ${tagNameAsPascal}CustomEvent<T> extends CustomEvent<T> {
detail: T;
target: HTML${tagNameAsPascal}Element;
}`;
const componentMeta = stubComponentCompilerMeta({
tagName,
});

const actualEventDetailTypes = generateEventDetailTypes(componentMeta);

expect(actualEventDetailTypes).toEqual<d.TypesModule>({
component: expectedTypeInfo,
element: expectedTypeInfo,
htmlElementName: `HTML${tagNameAsPascal}Element`,
isDep: false,
jsx: expectedTypeInfo,
tagName,
tagNameAsPascal,
});
});
});
});
29 changes: 18 additions & 11 deletions src/compiler/types/tests/generate-event-types.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,19 @@ describe('generate-event-types', () => {
it('returns an empty array when no events are provided', () => {
const stubImportTypes = stubTypesImportData();
const componentMeta = stubComponentCompilerMeta();
const cmpClassName = 'MyComponent';

expect(generateEventTypes(componentMeta, stubImportTypes)).toEqual([]);
expect(generateEventTypes(componentMeta, stubImportTypes, cmpClassName)).toEqual([]);
});

it('prefixes the event name with "on"', () => {
const stubImportTypes = stubTypesImportData();
const componentMeta = stubComponentCompilerMeta({
events: [stubComponentCompilerEvent()],
});
const cmpClassName = 'MyComponent';

const actualTypeInfo = generateEventTypes(componentMeta, stubImportTypes);
const actualTypeInfo = generateEventTypes(componentMeta, stubImportTypes, cmpClassName);

expect(actualTypeInfo).toHaveLength(1);
expect(actualTypeInfo[0].name).toBe('onMyEvent');
Expand All @@ -68,11 +70,12 @@ describe('generate-event-types', () => {
const componentMeta = stubComponentCompilerMeta({
events: [stubComponentCompilerEvent()],
});
const cmpClassName = 'MyComponent';

const actualTypeInfo = generateEventTypes(componentMeta, stubImportTypes);
const actualTypeInfo = generateEventTypes(componentMeta, stubImportTypes, cmpClassName);

expect(actualTypeInfo).toHaveLength(1);
expect(actualTypeInfo[0].type).toBe('(event: CustomEvent<UserImplementedEventType>) => void');
expect(actualTypeInfo[0].type).toBe('(event: MyComponentCustomEvent<UserImplementedEventType>) => void');
});

it('uses an updated type name to avoid naming collisions', () => {
Expand All @@ -83,11 +86,12 @@ describe('generate-event-types', () => {
const componentMeta = stubComponentCompilerMeta({
events: [stubComponentCompilerEvent()],
});
const cmpClassName = 'MyComponent';

const actualTypeInfo = generateEventTypes(componentMeta, stubImportTypes);
const actualTypeInfo = generateEventTypes(componentMeta, stubImportTypes, cmpClassName);

expect(actualTypeInfo).toHaveLength(1);
expect(actualTypeInfo[0].type).toBe(`(event: CustomEvent<${updatedTypeName}>) => void`);
expect(actualTypeInfo[0].type).toBe(`(event: MyComponentCustomEvent<${updatedTypeName}>) => void`);
});

it('derives CustomEvent type when there is no original typing field', () => {
Expand All @@ -102,8 +106,9 @@ describe('generate-event-types', () => {
const componentMeta = stubComponentCompilerMeta({
events: [componentEvent],
});
const cmpClassName = 'MyComponent';

const actualTypeInfo = generateEventTypes(componentMeta, stubImportTypes);
const actualTypeInfo = generateEventTypes(componentMeta, stubImportTypes, cmpClassName);

expect(actualTypeInfo).toHaveLength(1);
expect(actualTypeInfo[0].type).toBe('CustomEvent');
Expand All @@ -122,11 +127,12 @@ describe('generate-event-types', () => {
name: 'onMyEvent',
optional: false,
required: false,
type: '(event: CustomEvent<UserImplementedEventType>) => void',
type: '(event: MyComponentCustomEvent<UserImplementedEventType>) => void',
},
];
const cmpClassName = 'MyComponent';

const actualTypeInfo = generateEventTypes(componentMeta, stubImportTypes);
const actualTypeInfo = generateEventTypes(componentMeta, stubImportTypes, cmpClassName);

expect(actualTypeInfo).toEqual(expectedTypeInfo);
});
Expand All @@ -150,6 +156,7 @@ describe('generate-event-types', () => {
events: [componentEvent1, componentEvent2],
});
const stubImportTypes = stubTypesImportData();
const cmpClassName = 'MyComponent';

const expectedTypeInfo: d.TypeInfo = [
{
Expand All @@ -158,7 +165,7 @@ describe('generate-event-types', () => {
name: 'onMyEvent',
optional: false,
required: false,
type: '(event: CustomEvent<UserImplementedEventType>) => void',
type: '(event: MyComponentCustomEvent<UserImplementedEventType>) => void',
},
{
jsdoc: '',
Expand All @@ -170,7 +177,7 @@ describe('generate-event-types', () => {
},
];

const actualTypeInfo = generateEventTypes(componentMeta, stubImportTypes);
const actualTypeInfo = generateEventTypes(componentMeta, stubImportTypes, cmpClassName);

expect(actualTypeInfo).toEqual(expectedTypeInfo);
});
Expand Down
Loading

0 comments on commit 846740f

Please sign in to comment.