원클릭으로
migrate-component
Migrate a React-only component to the shared core/React/Vue architecture
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
Migrate a React-only component to the shared core/React/Vue architecture
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
SOC 직업 분류 기준
Add or update Vue documentation for a component: Vue demo files (.vue SFCs), PropTable integration (props/events/slots), and MDX page updates. Use when a component in @lumx/vue needs Vue demos or prop table documentation added alongside existing React documentation.
Debug visual differences in LumX design system Storybook screenshots. Covers both cross-framework diffs (React vs Vue pixel mismatches) and intra-framework diffs (baseline regressions within React or Vue). Use when visual-diffs report shows differences, when `yarn visual-diffs` produces diff images, when migrating components to Vue and need visual parity, or when Storybook screenshots change unexpectedly.
Scaffolding rules and conventions for React components in the @lumx/react package. Use when creating a new component, adding a sub-component to an existing family, or needing guidance on component file structure, naming, testing, and export conventions in the design system.
| name | migrate-component |
| description | Migrate a React-only component to the shared core/React/Vue architecture |
| argument-hint | component-name |
Migrate a React-only component to the core/React/Vue architecture pattern.
/migrate-component <ComponentName>
Example:
/migrate-component Switch
/migrate-component Table
This skill migrates a component (or component family) from React-only implementation to a shared core architecture where:
@lumx/core): Contains framework-agnostic UI logic, tests (plain data only, no JSX), and stories (JSX renders using injected framework components)@lumx/react): Thin wrapper that delegates to core@lumx/vue): Thin wrapper that delegates to coreComponent Families:
Component Organization:
Badge and BadgeWrapper both live in the badge/ folder
/packages/lumx-core/src/js/components/Badge/ contains both Badge and BadgeWrapper/packages/lumx-react/src/components/badge/ contains both Badge.tsx and BadgeWrapper.tsx/packages/lumx-vue/src/components/badge/ contains both Badge.tsx and BadgeWrapper.tsxTable family in the table/ folder
/packages/lumx-core/src/js/components/Table/ contains index.tsx (Table), TableRow.tsx, TableCell.tsx, etc./packages/lumx-react/src/components/table/ contains Table.tsx, TableRow.tsx, TableCell.tsx, etc./packages/lumx-vue/src/components/table/ contains Table.tsx, TableRow.tsx, TableCell.tsx, etc.badge/, table/), while the component names use PascalCase (e.g., Badge, BadgeWrapper, Table, TableRow)Before running this skill, ensure:
@lumx/react and is fully functionalGoal: Discover all components in the family and determine the correct migration order.
IMPORTANT: This phase MUST be completed before starting any migration work.
Check for existing core implementation:
@lumx/core/packages/lumx-core/src/js/components/<ComponentName>/ to see if any files existindex.tsx, <SubComponent>.tsx, constants.tsStories.tsx (or Stories.ts)Tests.tsDiscover component family:
/packages/lumx-react/src/components/<component-name>/index.ts to find all exported components.tsx files in the component folderAnalyze dependencies:
constants.ts file (dependency on parent)@lumx/react or @lumx/core (external dependencies)@lumx/coreDetermine migration order:
constants.ts file, migrate the parent component first (it will create the constants in core)Present migration plan to developer:
Example for Table family:
Discovered components:
1. Table (parent - defines constants.ts)
2. TableBody (imports TABLE_CLASSNAME from constants)
3. TableCell (imports TABLE_CLASSNAME from constants, uses Icon)
4. TableHeader (imports TABLE_CLASSNAME from constants)
5. TableRow (imports TABLE_CLASSNAME from constants)
Dependencies:
- All sub-components depend on constants.ts (created by Table)
- TableCell uses Icon (already available in core ✓)
Migration order:
1. Table (parent, creates constants)
2. TableBody
3. TableCell
4. TableHeader
5. TableRow
Validation Checkpoint 0:
This skill has MANDATORY validation checkpoints where you MUST stop and wait for user approval:
At each checkpoint:
IMPORTANT: After Phase 0 approval, migrate each component in the determined order by going through Phases 1-6 for each component before moving to the next.
Goal: Extract the core UI logic and create thin wrappers for React and Vue.
IMPORTANT - Check for Existing Core Implementation:
Create core component files:
packages/lumx-core/src/js/components/<ComponentName>/
├── constants.ts (migrate from React)
└── index.tsx (parent component)
packages/lumx-core/src/js/components/<ComponentName>/
└── index.tsx
TableRow after Table):
packages/lumx-core/src/js/components/<ParentComponentName>/
├── index.tsx (parent component)
└── <SubComponentName>.tsx (e.g., BadgeWrapper.tsx)
BadgeWrapper.tsx), not index.tsxExtract UI logic:
constants.ts to core (keep exact same structure)@lumx/core/js/components/<Component>/constantsimport { CLASSNAME as PARENT_CLASSNAME } from './constants' → import { CLASSNAME as PARENT_CLASSNAME } from '@lumx/core/js/components/<Parent>/constants'children prop to label: JSXElement (framework-agnostic)inputId: string prop if needed (generated by wrappers)InputLabel({ ... }) instead of <InputLabel ... />onCustomEvent, onSpecialAction) defined in core UIPropsToOverride in /packages/lumx-core/src/js/types/jsx/PropsToOverride.ts to include themComponent, ComponentProps, COMPONENT_NAME, CLASSNAME, DEFAULT_PROPS (or import from constants if applicable)Update React wrapper:
ReactToJSX type utility from @lumx/react/utils/type/ReactToJSXReactToJSX<UIProps, 'additionalPropsToOmit'> instead of manual OmitforwardRefuseId, useTheme, useDisableStateProps, useMergeRefschildren → label for core componentUI({ ... }) instead of rendering JSXCreate Vue wrapper:
packages/lumx-vue/src/components/<component-name>/
├── <Component>.tsx
└── index.ts
BadgeWrapper alongside Badge), add to existing parent folder:
packages/lumx-vue/src/components/<parent-component-name>/
├── <ParentComponent>.tsx
├── <SubComponent>.tsx (e.g., BadgeWrapper.tsx)
└── index.ts (update to export both components)
defineComponent with render functionuseTheme, useId, useDisableStatePropslabel prop and default slotreturn (<ComponentUI ... />)event.stopImmediatePropagation()keysOf<ComponentProps>()name: 'Lumx<Component>'inheritAttrs: falseindex.ts with exports for components and types only:
index.ts file for referenceCLASSNAME, COMPONENT_NAME, or DEFAULT_PROPS — these are internal constantsexport { default as Component }export { Component }Update Vue package index:
export * from './components/<component-name>';
🛑 MANDATORY Validation Checkpoint 1 - STOP HERE:
yarn test to ensure no regressionsyarn type-check to verify TypeScript compilationGoal: Create shared core stories with JSX renders and thin framework-specific wrappers.
KEY ARCHITECTURE:
.tsx file) with framework components injected via a components parametercomponents and decorators to core, then re-export stories.vue template files for stories — all rendering is done via JSX in corewithRender utility — replaced by JSX render functions defined in coreHOW IT WORKS:
.tsx files use "jsx": "preserve" — the JSX is NOT compiled by core.tsx file, React's toolchain compiles JSX to React.createElementh() calls@lumx/react or @lumx/vue)IMPORTANT - Check for Existing Core Stories:
Stories.ts or Stories.tsx already exists in core, review it and plan changesRead and analyze existing React stories:
components parameterGenericBlock) — these stay in the framework fileCreate core stories (packages/lumx-core/src/js/components/<ComponentName>/Stories.tsx):
.tsx — JSX is used for render functionssetup() function that takes { component, components, render, decorators } and returns story configurationscomponents parameter receives framework-specific component implementations (e.g., { Badge, Icon })overrides only when a story needs completely different structure per framework (rare)KEY RULES for core stories:
args — all JSX must live in render functions. This includes args.children, args.before, args.after, args.badge, and any other prop. JSX in args causes errors in Vue storybook tests (vitest). Only serializable data (strings, numbers, booleans, enums, objects) should be in args.withCombinations rows/sections/cols — combination values are merged into args at runtime, so they have the same restriction. Use render functions to produce JSX for different variants instead.meta as individual const variables — this enables stories to reference each other (e.g., WithIcon.render reused by AllTypography). Return them as a flat object: return { meta, StoryA, StoryB, ... }.render function — the render function receives args (serializable data) and returns JSX using the injected framework components.render — e.g., AllTypography can set render: WithIcon.render to reuse the same rendering.AllColors can compose {WithText.render(args)}, {WithIcon.render(args)} together.children out of args in render functions — when a render function provides its own inline JSX children, it must destructure children from the args to prevent the inherited children value (from meta.args) from leaking via {...args} onto the component. In Vue, spreading children as a prop on a DOM element causes a "Failed setting prop children" warning because children is a read-only DOM property. Use ({ children, ...args }: any) => instead of (args: any) =>.Pattern:
```tsx
import type { SetupStoriesOptions } from '@lumx/core/stories/types';
import { colorArgType } from '@lumx/core/stories/controls/color';
import { withUndefined } from '@lumx/core/stories/controls/withUndefined';
import { mdiHeart } from '@lumx/icons';
import { ColorPalette } from '../../constants';
import { DEFAULT_PROPS } from '.';
export function setup({
component: Badge,
components: { Icon, Thumbnail, FlexBox },
decorators: { withCombinations },
}: SetupStoriesOptions<{
decorators: 'withCombinations';
components: { Icon: any; Thumbnail: any; FlexBox: any };
}>) {
// Define meta and each story as individual consts
const meta = {
component: Badge,
render: (args: any) => <Badge {...args} />,
argTypes: {
color: colorArgType,
},
args: DEFAULT_PROPS,
};
/** Using badge with text children */
const WithText = {
// JSX in render, NOT in args
render: (args: any) => (
<Badge {...args}>
<span>30</span>
</Badge>
),
};
/** With icon child — uses Icon from injected components */
const WithIcon = {
render: (args: any) => (
<Badge {...args}>
<Icon icon={mdiHeart} />
</Badge>
),
};
/** All color combinations — composes other stories' renders */
const AllColors = {
render: (args: any) => (
<FlexBox orientation="vertical" gap="regular">
{WithText.render(args)}
{WithIcon.render(args)}
</FlexBox>
),
argTypes: { color: { control: false } },
decorators: [
withCombinations({
combinations: {
// Only serializable data in combinations — NO JSX
cols: { key: 'color', options: withUndefined(ColorPalette) },
},
}),
],
};
// Return flat object with all consts
return { meta, WithText, WithIcon, AllColors };
}
```
More examples of the pattern:
When a story just needs different args (no JSX), it doesn't need a custom render:
```tsx
/** Story with only serializable args — inherits meta render */
const Disabled = {
args: { isDisabled: true },
};
```
When multiple stories share the same render:
```tsx
/** Text with inline icons — destructure children to prevent leaking from meta.args */
const WithIcon = {
render: ({ children, ...args }: any) => (
<Text {...args}>
Some text <Icon icon={mdiHeart} /> with icons <Icon icon={mdiEarth} />
</Text>
),
};
/** All typographies — reuses WithIcon's render */
const AllTypography = {
render: WithIcon.render,
argTypes: { typography: { control: false } },
decorators: [
withCombinations({
combinations: {
rows: { key: 'typography', options: withUndefined(ALL_TYPOGRAPHY) },
},
}),
],
};
```
When a component has slot-like props (before, after, badge), put the JSX in render, not args:
```tsx
const DefaultRender = render || ((args: any) => <Toolbar {...args} />);
/** Toolbar with all content areas */
const WithAll = {
render: () => (
<DefaultRender
before={<Icon icon={mdiMenu} />}
after={<Icon icon={mdiMagnify} />}
label="Page title"
/>
),
};
```
Update React stories to use core setup:
Import setup from core stories
Pass framework components via components and decorators via decorators
Export each story as a thin re-export: export const StoryName = { ...stories.StoryName };
Add framework-only stories (using components not available in core) as separate exports
Pattern:
import { Badge, Icon } from '@lumx/react';
import { withCombinations } from '@lumx/react/stories/decorators/withCombinations';
import { setup } from '@lumx/core/js/components/Badge/Stories';
const { meta, ...stories } = setup({
component: Badge,
components: { Icon },
decorators: { withCombinations },
});
export default {
title: 'LumX components/badge/Badge',
...meta,
};
export const WithText = { ...stories.WithText };
export const WithIcon = { ...stories.WithIcon };
export const AllColors = { ...stories.AllColors };
// Framework-only story (uses components not in core setup)
export const WithThumbnail = {
args: {
children: <Thumbnail ... />,
},
};
🛑 MANDATORY Validation Checkpoint 2a - STOP HERE:
yarn type-check to verify TypeScript compilationCreate Vue stories as thin wrapper (.tsx file):
setup from core storiescomponents and Vue decorators via decorators.vue template files needed — all rendering handled by JSX in corerender override when the Vue component uses slots instead of props. Since core stories now put JSX in render functions (not args), the render override maps slot-like props to Vue slots when the core render passes them as JSX props/children.Pattern A — Default slot only (e.g., children or label prop → default slot):
```tsx
import { Flag } from '@lumx/vue';
import { setup } from '@lumx/core/js/components/Flag/Stories';
const { meta, ...stories } = setup({
component: Flag,
// Destructure `label` out of args and pass it as default slot (JSX children)
render: ({ label, ...args }: any) => <Flag {...args}>{label}</Flag>,
decorators: { /* ... */ },
});
```
Pattern B — Named slots (e.g., before, after, label → named slots):
```tsx
import { Toolbar, Icon } from '@lumx/vue';
import { setup } from '@lumx/core/js/components/Toolbar/Stories';
const { meta, ...stories } = setup({
component: Toolbar,
components: { Icon },
// Map props to Vue named slots using Vue JSX slot object syntax
render: ({ label, before, after, ...args }: any) => (
<Toolbar {...args}>
{{
default: label ? () => label : undefined,
before: before ? () => before : undefined,
after: after ? () => after : undefined,
}}
</Toolbar>
),
});
```
Pattern C — No slot mapping needed (core render handles everything):
When the core stories already handle all JSX in their own render functions (the new default pattern), and the Vue component doesn't need slot mapping because the core render directly renders the component with children via JSX, no render override is needed:
```tsx
import { Badge, FlexBox, Icon, Thumbnail } from '@lumx/vue';
import { withCombinations } from '@lumx/vue/stories/decorators/withCombinations';
import { setup } from '@lumx/core/js/components/Badge/Stories';
const { meta, ...stories } = setup({
component: Badge,
components: { Icon, Thumbnail, FlexBox },
decorators: { withCombinations },
// No render override needed — core stories already define render per story
});
```
How to decide which pattern to use:
Stories.tsx — if stories define their own render functions that directly render the component with JSX children (the new pattern), Vue usually doesn't need a render override (Pattern C)meta.render that receives slot-like content via args (e.g., label, before, after), the Vue side needs to map those to slots (Pattern A or B).tsx file and check if it accesses slots (e.g., slots.default?.(), slots.before?.())slots.default?.() only → use Pattern Aslots.before?.(), slots.after?.()) → use Pattern BFull example with re-exports:
```tsx
import { Badge, FlexBox, Icon, Thumbnail } from '@lumx/vue';
import { withCombinations } from '@lumx/vue/stories/decorators/withCombinations';
import { setup } from '@lumx/core/js/components/Badge/Stories';
const { meta, ...stories } = setup({
component: Badge,
components: { Icon, Thumbnail, FlexBox },
decorators: { withCombinations },
});
export default {
title: 'LumX components/badge/Badge',
...meta,
};
export const WithText = { ...stories.WithText };
export const WithIcon = { ...stories.WithIcon };
export const AllColors = { ...stories.AllColors };
```
🛑 MANDATORY Validation Checkpoint 2b - STOP HERE:
yarn test to ensure no regressionsyarn type-check to verify TypeScript compilationGoal: Extract core tests and update framework-specific test suites.
IMPORTANT RULES:
onClick interactions in React tests, test emit('click') in Vue testscommonTestsSuiteVTL (Vue) or commonTestsSuiteRTL (React)IMPORTANT - Check for Existing Core Tests:
Tests.ts already exists in core, skip step 2 and proceed to step 3 (React tests update)Read and analyze existing React tests:
Create core tests (packages/lumx-core/src/js/components/<ComponentName>/Tests.ts):
NO JSX ELEMENTS or component calls allowed - Use plain data only
Export setup() function that takes props and SetupOptions
Export default test suite function that receives SetupOptions and contains describe/it blocks
Only migrate tests that use plain data (strings, numbers, booleans)
Follow the Button pattern exactly
Example pattern:
import { getByClassName } from '../../../testing/queries';
import { SetupOptions } from '../../../testing';
import { ColorPalette } from '../../constants';
const CLASSNAME = 'lumx-badge';
/**
* Mounts the component and returns common DOM elements / data needed in multiple tests further down.
*/
export const setup = (propsOverride: any = {}, { render, ...options }: SetupOptions<any>) => {
const props = { ...propsOverride };
const wrapper = render(props, options);
const badge = getByClassName(document.body, CLASSNAME);
return { props, badge, wrapper };
};
export default (renderOptions: SetupOptions<any>) => {
const { screen } = renderOptions;
describe('Badge core tests', () => {
describe('Props', () => {
it('should use default props', () => {
const { badge } = setup({ children: '30' }, renderOptions);
expect(badge.className).toContain('lumx-badge');
expect(badge.className).toContain('lumx-badge--color-primary');
expect(badge).toHaveTextContent(/30/);
});
it('should render color', () => {
const { badge } = setup({ children: 'Badge', color: ColorPalette.red }, renderOptions);
expect(badge).toHaveClass('lumx-badge--color-red');
});
});
});
};
Update React tests:
Import default export from core tests (the test suite)
Call the test suite with { render, screen } options
Keep React-specific tests (ref forwarding, theme context, JSX children)
Keep commonTestsSuiteRTL (React-specific)
Example pattern:
import { commonTestsSuiteRTL } from '@lumx/react/testing/utils';
import { getByClassName } from '@lumx/react/testing/utils/queries';
import { render, screen } from '@testing-library/react';
import { Badge, BadgeProps } from './Badge';
import BaseBadgeTests from '@lumx/core/js/components/Badge/Tests';
const CLASSNAME = Badge.className as string;
const setup = (propsOverride: Partial<BadgeProps> = {}) => {
const props: BadgeProps = {
children: <span>30</span>,
...propsOverride,
};
render(<Badge {...props} />);
const badge = getByClassName(document.body, CLASSNAME);
return { badge, props };
};
describe(`<${Badge.displayName}>`, () => {
// Run core tests
BaseBadgeTests({
render: (props: BadgeProps) => render(<Badge {...props} />),
screen,
});
// React-specific tests
describe('React', () => {
it('should render empty children', () => {
const { badge } = setup({ children: null });
expect(badge).toBeInTheDocument();
expect(badge).toBeEmptyDOMElement();
});
});
// Common tests suite
commonTestsSuiteRTL(setup, {
baseClassName: CLASSNAME,
forwardClassName: 'badge',
forwardAttributes: 'badge',
forwardRef: 'badge',
});
});
Create Vue tests (packages/lumx-vue/src/components/<component-name>/<Component>.test.ts):
IMPORTANT: Vue tests should mimic React tests exactly - Same structure with core tests, framework describe, and commonTestsSuite
Import default export from core tests (the test suite)
Import and use the core setup function
Call the test suite with render function that converts children to slots
Create a local setup function that wraps the core setup
Add commonTestsSuiteVTL (Vue equivalent of React's commonTestsSuiteRTL)
Add Vue-specific tests (emit events, disabled states) if needed
Use @testing-library/vue
Example pattern:
import { render, screen } from '@testing-library/vue';
import BaseBadgeTests, { setup } from '@lumx/core/js/components/Badge/Tests';
import { CLASSNAME } from '@lumx/core/js/components/Badge';
import { commonTestsSuiteVTL, SetupRenderOptions } from '@lumx/vue/testing';
import { Badge } from '.';
describe('<Badge />', () => {
const renderBadge = ({ children, ...props }: any, options?: SetupRenderOptions<any>) =>
render(Badge, {
...options,
props,
slots: children ? { default: children } : undefined,
});
// Run core tests
BaseBadgeTests({
render: renderBadge,
screen,
});
const setupBadge = (props: any = {}, options: SetupRenderOptions<any> = {}) =>
setup(props, { ...options, render: renderBadge, screen });
// Common tests suite
commonTestsSuiteVTL(setupBadge, {
baseClassName: CLASSNAME,
forwardClassName: 'div',
forwardAttributes: 'div',
forwardRef: 'div',
});
});
🛑 MANDATORY Validation Checkpoint 3 (Tests) - STOP HERE:
yarn test to ensure all tests passyarn type-check to verify TypeScript compilationImportant Notes:
children prop to slots.defaultnull children, Vue renders comment nodes <!---->commonTestsSuiteVTL to match React's commonTestsSuiteRTL structuresetup() function should return aliases if needed (e.g., const div = badge;) for commonTestsSuite compatibilityVerify React package already exports component
IMPORTANT: Complete this phase ONCE for the entire component family after all components are migrated.
Add entry under [Unreleased]:
For single component:
### Added
- `@lumx/vue`:
- Create the `<Component>` component
### Changed
- `@lumx/core`:
- Moved `<Component>` from `@lumx/react`
For component family:
### Added
- `@lumx/vue`:
- Create the `<Component>` component family (`<Component>`, `<SubComponent1>`, `<SubComponent2>`, etc.)
### Changed
- `@lumx/core`:
- Moved `<Component>` component family from `@lumx/react` (`<Component>`, `<SubComponent1>`, `<SubComponent2>`, etc.)
Example for Table:
### Added
- `@lumx/vue`:
- Create the `Table` component family (`Table`, `TableBody`, `TableCell`, `TableHeader`, `TableRow`)
### Changed
- `@lumx/core`:
- Moved `Table` component family from `@lumx/react` (`Table`, `TableBody`, `TableCell`, `TableHeader`, `TableRow`)
IMPORTANT: After completing Phases 1-5 for ALL components in the family, perform final verification.
Build packages:
yarn build:core
yarn build:react
yarn build:vue
Final smoke test:
yarn testValidate React/Vue parity:
CRITICAL: Check that all tests and stories are properly matched between React and Vue
For each component in the family:
Stories validation:
.stories.tsx file in React has a corresponding .stories.tsx file in Vuesetup() with identical structure# React stories
ls packages/lumx-react/src/components/<component-name>/*.stories.tsx
# Vue stories
ls packages/lumx-vue/src/components/<component-name>/*.stories.tsx
Tests validation:
Verify every .test.tsx file in React has a corresponding .test.ts file in Vue
Read test files and compare test structure:
describe blocks exist in both (React/Vue)commonTestsSuiteRTL (React) has equivalent commonTestsSuiteVTL (Vue)Check commonTestsSuite configurations match:
baseClassName, forwardClassName, forwardAttributes, forwardRef are presentapplyTheme config - Vue should have it tooExample validation:
// React test structure:
describe(`<${Component.displayName}>`, () => {
BaseComponentTests({ render, screen });
describe('React', () => {
/* React-specific tests */
});
commonTestsSuiteRTL(setup, {
/* config */
});
});
// Vue test structure should match:
describe('<Component />', () => {
BaseComponentTests({ render, screen });
describe('Vue', () => {
/* Vue-specific tests */
});
commonTestsSuiteVTL(setup, {
/* same config */
});
});
Fix any discrepancies found:
.stories.tsx calling core setup() with Vue componentsapplyTheme config → Add to Vue testRun tests again after fixes:
yarn test
IMPORTANT: After all components are migrated and verified, add Vue documentation to the site-demo.
Goal: Create Vue demo files and update the documentation MDX page to include both React and Vue frameworks.
Check for existing documentation:
packages/site-demo/content/product/components/<component>/index.mdxRun the vue-docs skill:
Skill tool: vue-docs, args: "<ComponentName>"
Manual verification:
frameworks: ['react', 'vue']Note: This phase must be completed for every component migration. If documentation doesn't exist, create a task or issue to add it later.
IMPORTANT: Use the new type utilities for cleaner and more maintainable props definitions.
Core Type Definition:
PropsToOverride = 'ref' | 'onClick' | 'onChange' | 'onKeyPress'React Props Pattern:
import { ReactToJSX } from '@lumx/react/utils/type/ReactToJSX';
// Automatically omits PropsToOverride ('ref' | 'onClick' | 'onChange' | 'onKeyPress')
export interface ComponentProps extends GenericProps, ReactToJSX<UIProps, 'inputId' | 'label'> {
children?: React.ReactNode;
onChange?(param: Type): void; // Define React-specific onChange signature
}
Vue Props Pattern:
import { VueToJSXProps } from '../../utils/VueToJSX';
// Automatically omits PropsToOverride + 'children' + 'className'
export type ComponentProps = VueToJSXProps<UIProps, 'inputId' | 'label'>;
Benefits:
ref, onClick, onChange, onKeyPress in every OmitPropsToOverride once to affect all componentsOmit<UIProps, 'children' | 'ref' | 'onClick' | 'onChange' | 'onKeyPress' | 'inputId' | 'label'>IMPORTANT: The index.ts files in React and Vue should export the same items in a similar structure, with only framework-specific differences.
React index.ts pattern:
export { Badge, type BadgeProps } from './Badge';
export { BadgeWrapper, type BadgeWrapperProps } from './BadgeWrapper';
export { CLASSNAME, COMPONENT_NAME, DEFAULT_PROPS } from '@lumx/core/js/components/Badge';
Vue index.ts pattern:
export { default as Badge, type BadgeProps } from './Badge';
export { default as BadgeWrapper, type BadgeWrapperProps } from './BadgeWrapper';
Key differences:
default as ComponentName for component exports (because Vue component files use default export)CLASSNAME, COMPONENT_NAME, or DEFAULT_PROPS — these are internal constants meant for core/React onlyimport type { JSXElement, LumxClassName, HasTheme, HasClassName, CommonRef } from '../../types';
import { classNames } from '../../utils';
import { InputLabel } from '../InputLabel';
import { InputHelper } from '../InputHelper';
export interface ComponentProps extends HasTheme, HasClassName, HasAriaDisabled {
helper?: string;
inputId: string; // Required
label?: JSXElement; // Not 'children'
// ... other props
}
export const COMPONENT_NAME = 'Component';
export const CLASSNAME: LumxClassName<typeof COMPONENT_NAME> = 'lumx-component';
export const DEFAULT_PROPS: Partial<ComponentProps> = {};
export const Component = (props: ComponentProps) => {
const { label, inputId, helper, /* ... */ } = props;
return (
<div className={/* ... */}>
{/* Component structure */}
{label && InputLabel({ htmlFor: inputId, children: label })}
{helper && InputHelper({ id: `${inputId}-helper`, children: helper })}
</div>
);
};
import React from 'react';
import {
Component as UI,
ComponentProps as UIProps,
CLASSNAME,
COMPONENT_NAME,
} from '@lumx/core/js/components/Component';
import { useId, useTheme, useDisableStateProps, useMergeRefs } from '@lumx/react/...';
import { ReactToJSX } from '@lumx/react/utils/type/ReactToJSX';
export interface ComponentProps extends GenericProps, ReactToJSX<UIProps, 'inputId' | 'label'> {
children?: React.ReactNode;
}
export const Component = forwardRef<ComponentProps, HTMLDivElement>((props, ref) => {
const { isAnyDisabled, disabledStateProps, otherProps } = useDisableStateProps(props);
const defaultTheme = useTheme() || Theme.light;
const { children, id, inputRef /* ... */ } = otherProps;
const localInputRef = React.useRef<HTMLInputElement>(null);
const generatedInputId = useId();
const inputId = id || generatedInputId;
return UI({
ref,
label: children, // Map children → label
inputId,
inputRef: useMergeRefs(inputRef, localInputRef),
theme: defaultTheme,
isDisabled: isAnyDisabled,
inputProps: {
...inputProps,
...disabledStateProps,
readOnly: inputProps.readOnly || isAnyDisabled,
},
...otherProps,
});
});
import { computed, defineComponent, toRaw, useAttrs } from 'vue';
import {
Component as ComponentUI,
type ComponentProps as UIProps,
} from '@lumx/core/js/components/Component';
import { useClassName } from '../../composables/useClassName';
import { useTheme, useDisableStateProps, useId } from '../../composables/...';
import { keysOf, VueToJSXProps } from '../../utils/VueToJSX';
import { JSXElement } from '@lumx/core/js/types';
export type ComponentProps = VueToJSXProps<UIProps, 'inputId' | 'label'>;
export const emitSchema = {
change: (/* params */) => /* validation */,
};
const Component = defineComponent(
(props: ComponentProps, { emit, slots }) => {
const attrs = useAttrs();
const className = useClassName(() => props.class);
const defaultTheme = useTheme();
const generatedInputId = useId();
const inputId = computed(() => props.id || generatedInputId);
const { isAnyDisabled, disabledStateProps, otherProps } = useDisableStateProps(
computed(() => ({ ...props, ...attrs })),
);
const handleChange = (/* params */) => {
if (isAnyDisabled.value) return;
event.stopImmediatePropagation(); // Important!
emit('change', /* params */);
};
return () => {
// Unwrap linkAs from reactivity before passing to core (see pitfall #22)
const { linkAs, ...rest } = otherProps.value;
// Use JSX rendering
return (
<ComponentUI
{...rest}
linkAs={toRaw(linkAs)}
className={className.value}
theme={props.theme || defaultTheme}
inputId={inputId.value}
isDisabled={isAnyDisabled.value}
onChange={handleChange}
label={(props.label || slots.default?.()) as JSXElement}
inputProps={{
...props.inputProps,
...disabledStateProps.value,
readOnly: isAnyDisabled.value,
}}
/>
);
};
},
{
name: 'LumxComponent', // Prefix with 'Lumx'
inheritAttrs: false,
props: keysOf<ComponentProps>()(/* list all props */),
emits: emitSchema,
},
);
export default Component;
onCustomEvent, onSpecialAction)/packages/lumx-core/src/js/types/jsx/PropsToOverride.ts'ref' | 'onClick' | 'onChange' | 'onKeyPress' | 'onCustomEvent')args or withCombinations values — All JSX must live in render functions. JSX in args (including args.children, args.before, args.after, args.badge) causes errors in Vue storybook tests. Combination rows/sections/cols values are merged into args at runtime, so they have the same restriction.
render: (args: any) => <Badge {...args}><Icon icon={mdiHeart} /></Badge>args: { color: 'red', isDisabled: true } (serializable data only)args: { children: <Icon icon={mdiHeart} /> } (JSX in args)rows: { 'With icon': { children: <Icon icon={mdiHeart} /> } } (JSX in combinations)const variables — This enables cross-referencing between stories (e.g., reusing a render function, composing multiple renders). Return a flat object: return { meta, StoryA, StoryB, ... }.
const WithIcon = { render: (args) => ... }; const AllTypography = { render: WithIcon.render, ... };render override when the Vue component uses slots AND the core meta.render passes content via args — When each core story defines its own render that directly renders JSX children (the new default pattern), Vue usually doesn't need a render override. But when the core meta.render receives slot-like content via args (e.g., label, before, after), the Vue stories must map props to slots.
render: ({ label, ...args }: any) => <Flag {...args}>{label}</Flag>render: ({ label, before, after, ...args }: any) => (<Toolbar {...args}>{{ default: label ? () => label : undefined, before: before ? () => before : undefined }}</Toolbar>).tsx file. If it accesses slots (e.g., slots.default?.(), slots.before?.()) for any of the content props used in the core stories, a render override may be required.components parameter. Never import from @lumx/react or @lumx/vue in core stories.
render: (args) => <Badge {...args}><Icon icon={mdiHeart} /></Badge> (JSX in render, using Icon from components param)args: { children: <Icon icon={mdiHeart} /> } (JSX in args)import { Icon } from '@lumx/react' (hardcoded framework import)children: '30', use SetupOptions pattern with default exportchildren: <Icon /> or JSX render functionschildren out of args in story render functions — When a render function provides its own inline JSX children, it must destructure children from the args to prevent the inherited children value (from meta.args) from leaking via {...args} onto the component. In Vue, spreading children as a prop on a DOM element causes "Failed setting prop children" warnings.
render: ({ children, ...args }: any) => <Link {...args}>Link <Icon icon={mdiEarth} /> with icon</Link>render: (args: any) => <Link {...args}>Link <Icon icon={mdiEarth} /> with icon</Link> (children from meta.args leaks as a DOM prop)Children.count() in core - This is React-specificInputLabel({ ... }) not <InputLabel ... />return (<Component />) not function calls'LumxComponent', not 'Component'.tsx extension for ALL story files — core, React, and Vue stories all use JSX (Vue stories need .tsx when they provide a render override with JSX).vue template files for stories — all story rendering is handled by JSX in core. Do NOT create Stories/*.vue files or use withRender.commonTestsSuiteVTLindex.ts should export components and types only - Do NOT export CLASSNAME, COMPONENT_NAME, or DEFAULT_PROPS from Vue index files. Only export components (using default as syntax) and types (props, enums)useClassName composable for merging class prop with className attr — Core JSX components pass className (React convention) when rendering Vue sub-components. Since className is not a declared Vue prop, it lands in $attrs. Use useClassName(() => props.class) to merge both sources with classNames.join(). Always pass a getter (() => props.class), not props.class directly — the plain string loses reactivity. useClassName is built on useAttrFallback — a generic composable that falls back to any $attrs value when a Vue prop is absent (e.g., tabIndex from core lands as attrs.tabindex in Vue). Use useAttrFallback directly for other React-named attrs besides className.
const className = useClassName(() => props.class); then className={className.value}const tabIndex = useAttrFallback(() => attrs.tabindex, 'tabIndex'); (for non-className attrs)className={props.class} (ignores attrs.className from core parent components)const className = useClassName(props.class); (loses reactivity — captures stale value at setup time)toRaw() on props that accept Vue component references — Props like linkAs accept a Vue component object (e.g., a custom RouterLink). When this component object flows through Vue's reactivity system (props → computed → spread in useDisableStateProps), it becomes a reactive proxy. Passing a reactive component to JSX causes: [Vue warn]: Vue received a Component that was made a reactive object. Use toRaw() to unwrap reactivity before passing to the core UI component.
const { linkAs, ...rest } = otherProps.value; <CoreUI {...rest} linkAs={toRaw(linkAs)} /><CoreUI {...otherProps.value} /> (linkAs is still a reactive proxy)linkAs. Props like as on Text/Heading/FlexBox only accept string tag names ('span', 'p', 'h1', 'div'), which are primitives immune to reactivity — no toRaw() needed for those.Link, Button, IconButton, Thumbnail — any component with a linkAs prop.import { toRaw } from 'vue';constants.ts → migrate parent firstconst pattern, render reuse across stories, and withCombinationsrender across stories (WithIcon.render reused by AllTypography and AllColors)const variables, no JSX in args, story render composition (AllColors composes WithText.render, WithIcon.render, WithThumbnail.render), and FlexBox for layoutrender override for slot mappingTable + sub-components TableBody, TableCell, TableHeader, TableRowIMPORTANT: This checklist applies to EACH component in the family. Complete all phases for one component before moving to the next (following the migration order from Phase 0).
Note: For sub-components (e.g., BadgeWrapper alongside Badge, or TableRow alongside Table), replace <Component> with the parent folder name (e.g., Badge, Table), and use <SubComponent>.tsx instead of index.tsx in core.
Note: For parent components with constants.ts, create the constants file in core during the first component migration.
/packages/lumx-core/src/js/components/<Component>/index.tsx (created) or <Component>/<SubComponent>.tsx for sub-components/packages/lumx-react/src/components/<component>/<Component>.tsx (modified to wrapper)/packages/lumx-vue/src/components/<component>/<Component>.tsx (created)/packages/lumx-vue/src/components/<component>/index.ts (created or updated to export sub-component)/packages/lumx-vue/src/index.ts (export already exists for parent folder)Step 1: Core Stories
components parameter/packages/lumx-core/src/js/components/<Component>/Stories.tsx (created - JSX with injected components)Step 2: React Stories
/packages/lumx-react/src/components/<component>/<Component>.stories.tsx (modified - thin wrapper + re-exports)Step 3: Vue Stories
/packages/lumx-vue/src/components/<component>/<Component>.stories.tsx (created - thin wrapper + re-exports)/packages/lumx-core/src/js/components/<Component>/Tests.ts (created - NO JSX, plain data only)/packages/lumx-react/src/components/<component>/<Component>.test.tsx (modified)/packages/lumx-vue/src/components/<component>/<Component>.test.ts (created)/CHANGELOG.md (add entry under Unreleased)When migrating a component family (e.g., Table, TableRow, TableCell, etc.):
Example Timeline for Table Family:
Phase 0: Discovery & Planning (all components)
├─ Component 1: Table
│ ├─ Phase 1: UI Extraction → ✓
│ ├─ Phase 2: Stories → ✓
│ ├─ Phase 3: Tests → ✓
│ └─ Phase 4: Package Exports → ✓
├─ Component 2: TableBody
│ ├─ Phase 1: UI Extraction → ✓
│ ├─ Phase 2: Stories → ✓
│ ├─ Phase 3: Tests → ✓
│ └─ Phase 4: Package Exports → ✓
├─ ... (repeat for TableCell, TableHeader, TableRow)
├─ Phase 5: CHANGELOG (entire family)
└─ Phase 6: Final Build Verification (entire family)
yarn test passesyarn type-check passesAfter Step 1 (Core Stories):
components parameter identifiedStories.tsx created with JSX renders using injected componentsAfter Step 2 (React Stories - Checkpoint 2a):
.tsx wrapper created with components and decoratorsyarn type-check passesAfter Step 3 (Vue Stories - Checkpoint 2b):
.tsx wrapper created with components and decoratorsyarn test passesyarn type-check passesyarn test passesyarn type-check passesyarn build:core, yarn build:react, yarn build:vue)