[FRONT COMPONENTS] Twenty UI elements generation (#17866)

## PR description

This PR:
- Creates a TypeScript-based extractor that discovers all exported
twenty-ui components by scanning barrel files, extracting
props/slots/events via ts-morph type analysis, and generating the remote
DOM bindings automatically.

- Adds a new ESLint rule which enforces all *Props types in twenty-ui
components to be exported, which is required for the extractor to
discover component prop types. Existing twenty-ui components are updated
to comply with this rule.

- Extends the remote DOM generation to support slots, per-component
events, forwardRef wrappers, and richer property types (array, object,
function)

## Edge cases to fix in another PR

- Icons cannot be rendered inside buttons
- IconButtons throw an error when mounted
- MenuItems are not displayed correctly
- MenuItemNavigate throws on click

## Video Demo


https://github.com/user-attachments/assets/c2ed67cf-6a15-4896-9fec-e83fac0e862b
This commit is contained in:
Raphaël Bosi
2026-02-12 12:48:16 +01:00
committed by GitHub
parent c48ccbca99
commit cb7d0b83a8
133 changed files with 10556 additions and 984 deletions
@@ -1,8 +1,8 @@
import {
type CodeBlockWriter,
type Project,
type SourceFile,
VariableDeclarationKind,
type WriterFunction,
} from 'ts-morph';
import {
@@ -13,7 +13,6 @@ import {
import { type ComponentSchema, type PropertySchema } from './schemas';
import {
addFileHeader,
generatePropertiesConfig,
schemaTypeToConstructor,
schemaTypeToTs,
} from './utils';
@@ -147,7 +146,9 @@ const generateElementPropertyType = (
isExported: true,
name: `${component.name}Properties`,
type: (writer) => {
writer.write(`${TYPE_NAMES.COMMON_PROPERTIES} & `);
if (component.isHtmlElement) {
writer.write(`${TYPE_NAMES.COMMON_PROPERTIES} & `);
}
writer.block(() => {
for (const entry of entries) {
writer.writeLine(`${entry};`);
@@ -158,12 +159,14 @@ const generateElementPropertyType = (
}
};
const createPropertiesConfigWriter = (
const writePropertyEntries = (
writer: CodeBlockWriter,
properties: Record<string, PropertySchema>,
): WriterFunction => {
return (writer) => {
writer.write(generatePropertiesConfig(properties));
};
): void => {
for (const [name, schema] of Object.entries(properties)) {
const constructorType = schemaTypeToConstructor(schema.type);
writer.writeLine(`'${name}': { type: ${constructorType} },`);
}
};
const generateElementDefinition = (
@@ -172,17 +175,23 @@ const generateElementDefinition = (
specificProperties: Record<string, PropertySchema>,
options: ElementGenerationOptions,
): void => {
const { useSharedEvents, useSharedPropertiesConfig } = options;
const useSharedEvents = options.useSharedEvents && component.isHtmlElement;
const { useSharedPropertiesConfig } = options;
const hasEvents = component.events.length > 0;
const hasSpecificProps = Object.keys(specificProperties).length > 0;
const hasProps = Object.keys(component.properties).length > 0;
const hasSlots = (component.slots ?? []).length > 0;
const propsType = hasSpecificProps
? `${component.name}Properties`
: hasProps
: hasProps && component.isHtmlElement
? TYPE_NAMES.COMMON_PROPERTIES
: TYPE_NAMES.EMPTY_RECORD;
const slotsType = hasSlots
? `{ ${(component.slots ?? []).map((slot) => `'${slot}': true`).join('; ')} }`
: TYPE_NAMES.EMPTY_RECORD;
const eventsType = hasEvents
? useSharedEvents
? TYPE_NAMES.COMMON_EVENTS
@@ -201,13 +210,13 @@ const generateElementDefinition = (
writer.indent(() => {
writer.writeLine(`${propsType},`);
writer.writeLine('Record<string, never>,');
writer.writeLine('Record<string, never>,');
writer.writeLine(`${slotsType},`);
writer.write(eventsType);
});
writer.newLine();
writer.write('>');
const hasConfig = hasProps || hasEvents;
const hasConfig = hasProps || hasEvents || hasSlots;
if (!hasConfig) {
writer.write('({})');
return;
@@ -215,34 +224,33 @@ const generateElementDefinition = (
writer.write('(');
writer.block(() => {
if (hasSlots) {
writer.write(
`slots: [${(component.slots ?? []).map((slot) => `'${slot}'`).join(', ')}],`,
);
writer.newLine();
}
if (hasProps) {
if (hasSpecificProps) {
if (hasSpecificProps && component.isHtmlElement) {
writer.write('properties: ');
writer.block(() => {
writer.writeLine(
`...${TYPE_NAMES.COMMON_PROPERTIES_CONFIG},`,
);
for (const [name, schema] of Object.entries(
specificProperties,
)) {
const constructorType = schemaTypeToConstructor(
schema.type,
);
writer.writeLine(
`'${name}': { type: ${constructorType} },`,
);
}
writePropertyEntries(writer, specificProperties);
});
writer.write(',');
writer.newLine();
} else if (useSharedPropertiesConfig) {
} else if (useSharedPropertiesConfig && component.isHtmlElement) {
writer.write(
`properties: ${TYPE_NAMES.COMMON_PROPERTIES_CONFIG},`,
);
writer.newLine();
} else {
writer.write('properties: ');
createPropertiesConfigWriter(component.properties)(writer);
writer.block(() => {
writePropertyEntries(writer, specificProperties);
});
writer.write(',');
writer.newLine();
}
@@ -313,10 +321,9 @@ const prepareComponentsWithSpecificProps = (
): ComponentWithSpecificProps[] => {
return components.map((component) => ({
component,
specificProperties: getElementSpecificProperties(
component,
commonPropertyNames,
),
specificProperties: component.isHtmlElement
? getElementSpecificProperties(component, commonPropertyNames)
: component.properties,
}));
};
@@ -381,12 +388,9 @@ export const generateRemoteElements = (
generateCustomElementRegistrations(sourceFile, components);
sourceFile.addExportDeclaration({
namedExports: [
INTERNAL_ELEMENT_CLASSES.ROOT,
INTERNAL_ELEMENT_CLASSES.FRAGMENT,
],
});
sourceFile.addStatements(
`export { ${INTERNAL_ELEMENT_CLASSES.ROOT}, ${INTERNAL_ELEMENT_CLASSES.FRAGMENT} };`,
);
generateTagNameMapDeclaration(sourceFile, components);