diff --git a/package.json b/package.json index 93ece02c..d2c95d51 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "validate": "concurrently npm:validate:*", "validate:types": "tsc --noEmit", "validate:prettier": "prettier -c scripts src test types karma.config.cjs vite.config.ts", + "validate:build": "tsx scripts/validate-build.ts", "version": "tsx scripts/package-json-version.ts && npm install --package-lock-only --ignore-scripts" }, "repository": { diff --git a/scripts/validate-build.ts b/scripts/validate-build.ts new file mode 100644 index 00000000..5e62b3d5 --- /dev/null +++ b/scripts/validate-build.ts @@ -0,0 +1,35 @@ +import { readFile } from 'node:fs/promises'; + +const root = new URL('../', import.meta.url); +const generated = new URL('generated/', root); + +/** + * This function validates that the generated TypeScript definition file for a component + * does not include properties that are inherited from a parent class or interface. + * + * It reads the content of the component's generated d.ts file, and checks for the presence of + * certain properties that are expected to be inherited. + * + * If any of these properties are found in the file, the function throws an error. + * + * @throws {Error} If the generated file contains any of the inherited properties. + */ +async function validateInheritedProperties() { + const typeDefinitionContent = await readFile(new URL('Checkbox.d.ts', generated), 'utf8'); + + // Expect the generated file not to contain definitions for properties that are inherited. + const inheritedProperties = [ + 'className', // From 'HTMLAttributes' + 'onClick', // From 'DOMAttributes' + 'theme', // From 'ThemePropertyMixinClass' + 'indeterminate', // From 'CheckboxMixin' + ]; + + for (const property of inheritedProperties) { + if (typeDefinitionContent.includes(`${property}:`) || typeDefinitionContent.includes(`${property}?:`)) { + throw new Error(`The generated type definition file contains inherited property "${property}".`); + } + } +} + +await Promise.all([validateInheritedProperties()]); diff --git a/src/utils/createComponent.ts b/src/utils/createComponent.ts index 186b9651..3a86a5a6 100644 --- a/src/utils/createComponent.ts +++ b/src/utils/createComponent.ts @@ -6,6 +6,7 @@ import { import type { ThemePropertyMixinClass } from '@vaadin/vaadin-themable-mixin/vaadin-theme-property-mixin.js'; import type React from 'react'; import type { RefAttributes } from 'react'; +import type { ControllerMixinClass } from '@vaadin/component-base/src/controller-mixin.js'; declare const __VERSION__: string; @@ -59,15 +60,24 @@ export type ThemedWebComponentProps< theme?: string; }; -export type WebComponentProps = I extends ThemePropertyMixinClass +type AllWebComponentProps = I extends ThemePropertyMixinClass ? ThemedWebComponentProps : _WebComponentProps; +// TODO: LoginOverlay has "autofocus" property so we can't omit it +type OmittedWebComponentProps = Omit | 'autofocus'> & ControllerMixinClass; + +export type WebComponentProps = Omit< + AllWebComponentProps, + keyof OmittedWebComponentProps +>; + // We need a separate declaration here; otherwise, the TypeScript fails into the // endless loop trying to resolve the typings. export function createComponent( options: Options, ): (props: WebComponentProps & RefAttributes) => React.ReactElement | null; + export function createComponent(options: Options): any { const { elementClass } = options; diff --git a/test/typings/api.ts b/test/typings/api.ts new file mode 100644 index 00000000..15a6b2c2 --- /dev/null +++ b/test/typings/api.ts @@ -0,0 +1,99 @@ +import React, { type HTMLAttributes, type RefAttributes } from 'react'; +import { TextField, TextFieldElement } from '../../TextField.js'; +import type { LitElement } from 'lit'; +import { GridColumn, GridColumnElement } from '../../GridColumn.js'; +import { Dialog, DialogElement } from '../../Dialog.js'; +import { DatePicker, DatePickerElement } from '../../DatePicker.js'; +import { LoginOverlay, LoginOverlayElement } from '../../LoginOverlay.js'; +import { TimePicker, type TimePickerChangeEvent } from '../../TimePicker.js'; +import { TextArea, TextAreaElement, type TextAreaChangeEvent } from '../../TextArea.js'; +import { MessageInput, MessageInputElement, type MessageInputSubmitEvent } from '../../MessageInput.js'; +import { ComboBox, type ComboBoxChangeEvent } from '../../ComboBox.js'; + +const assertType = (value: TExpected) => value; +const assertOmitted = (prop: keyof Omit) => prop; + +const textFieldProps = React.createElement(TextField, {}).props; +type TextFieldProps = typeof textFieldProps; + +type PartialTextFieldElement = Omit< + Partial, + 'draggable' | 'style' | 'translate' | 'children' | 'contentEditable' +>; + +assertType(textFieldProps); + +// Assert that certain properties are present +assertType(textFieldProps.label); +assertType(textFieldProps.value); +assertType(textFieldProps.hidden); +assertType(textFieldProps.slot); +assertType(textFieldProps.title); +assertType(textFieldProps.id); + +assertType['className']>(textFieldProps.className); +assertType['style']>(textFieldProps.style); +assertType['children']>(textFieldProps.children); +assertType['aria-label']>(textFieldProps['aria-label']); + +assertType['ref']>(textFieldProps.ref); + +// Assert that certain HTMLElement properties are NOT present +assertOmitted('append'); +assertOmitted('prepend'); +assertOmitted('ariaLabel'); + +// Assert that certain LitElement properties are NOT present +assertOmitted('renderRoot'); +assertOmitted('requestUpdate'); +assertOmitted('addController'); +assertOmitted('removeController'); + +const gridColumnProps = React.createElement(GridColumn, {}).props; +type GridColumnProps = typeof gridColumnProps; +assertType(gridColumnProps.hidden); + +assertOmitted('append'); +assertOmitted('prepend'); +assertOmitted('ariaLabel'); + +const dialogProps = React.createElement(Dialog, {}).props; +type DialogProps = typeof dialogProps; + +assertType(dialogProps['aria-label']); + +assertType(dialogProps.footer); + +const datePickerProps = React.createElement(DatePicker, {}).props; + +const datePickerOnChange: typeof datePickerProps.onChange = (event) => { + assertType(event.target.value); +}; + +const comboBoxProps = React.createElement(ComboBox, {}).props; + +assertType((_event: ComboBoxChangeEvent) => {}); + +const messageInputProps = React.createElement(MessageInput, {}).props; + +const messageInputOnSubmit: typeof messageInputProps.onSubmit = (event) => { + assertType(event.detail.value); +}; + +assertType((_event: MessageInputSubmitEvent) => {}); + +const textAreaProps = React.createElement(TextArea, {}).props; + +assertType((_event: TextAreaChangeEvent) => {}); + +const textAreaOnChange: typeof textAreaProps.onChange = (event) => { + assertType(event.target.value); +}; + +const timePickerProps = React.createElement(TimePicker, {}).props; + +assertType((_event: TimePickerChangeEvent) => {}); + +const loginOverlayProps = React.createElement(LoginOverlay, {}).props; + +assertType(loginOverlayProps.autofocus);