Migrate Component to Core Architecture
Migrate a React-only component to the core/React/Vue architecture pattern.
Usage
code
/migrate-component <ComponentName>
Example:
code
/migrate-component Switch
Description
This skill migrates a component from React-only implementation to a shared core architecture where:
- •Core package (
@lumx/core): Contains framework-agnostic UI logic, tests, and stories - •React package (
@lumx/react): Thin wrapper that delegates to core - •Vue package (
@lumx/vue): Thin wrapper that delegates to core
Prerequisites
Before running this skill, ensure:
- •The component exists in
@lumx/reactand is fully functional - •The component has existing tests and stories
- •A reference component (like Checkbox) has already been migrated and can serve as a pattern
Migration Steps
Phase 1: Create Core Component
- •
Create core component directory:
codepackages/lumx-core/src/js/components/<ComponentName>/ ├── index.tsx ├── Tests.ts └── Stories.ts
- •
Extract UI logic to
index.tsx:- •Change
childrenprop tolabel: JSXElement(framework-agnostic) - •Add required
inputId: stringprop (generated by wrappers) - •Use functional JSX calls:
InputLabel({ ... })instead of<InputLabel ... /> - •Remove React-specific code (Children.count, etc.)
- •Export:
Component,ComponentProps,COMPONENT_NAME,CLASSNAME,DEFAULT_PROPS
- •Change
- •
Create
Tests.ts:- •Export
setup()function returning test helpers - •Export default test suite function
- •Move UI-related tests from React
- •Keep framework-specific tests in wrappers
- •Export
- •
Create
Stories.ts:- •Export
setup()function returning story configurations - •Create stories: Default, with variations, Disabled
- •Export
Phase 2: Update React Wrapper
- •
Refactor React component:
- •Import UI component from core
- •Transform into thin wrapper using
forwardRef - •Use hooks:
useId,useTheme,useDisableStateProps,useMergeRefs - •Map
children→labelfor core component - •Call
UI({ ... })instead of rendering JSX - •Maintain backward compatibility
- •
Update React tests:
- •Import and run
BaseComponentTestsfrom core - •Keep only React-specific tests (ref forwarding, theme context)
- •Create adapter to map props for core tests
- •Import and run
- •
Update React stories:
- •Import
setupfrom core stories - •Add framework-specific decorators
- •Re-export stories
- •Import
Phase 3: Create Vue Wrapper
- •
Create Vue component structure:
codepackages/lumx-vue/src/components/<component-name>/ ├── <Component>.tsx ├── <Component>.test.ts ├── <Component>.stories.ts ├── Stories/ │ └── <Component>Default.vue └── index.ts
- •
Create Vue wrapper (
<Component>.tsx):- •Use
defineComponentwith render function - •Use composables:
useTheme,useId,useDisableStateProps - •Support both
labelprop and default slot - •Emit events instead of onChange callbacks
- •Use JSX rendering:
return (<ComponentUI ... />) - •Add stop propagation:
event.stopImmediatePropagation() - •Define props using
keysOf<ComponentProps>() - •Set
name: 'Lumx<Component>' - •Set
inheritAttrs: false
- •Use
- •
Create Vue tests:
- •Import and run core tests
- •Add Vue-specific tests (emit events, disabled states)
- •Use
@testing-library/vue
- •
Create Vue stories:
- •Import core story setup
- •Use
withRenderdecorator - •Disable
isCheckedcontrol (managed internally)
- •
Create story template (
.vuefile):- •Internal state management with
ref - •Use
useAttrsWithoutHandlers - •Handle change events
- •Internal state management with
- •
Create
index.ts:- •Export component, props, and constants
Phase 4: Update Package Exports
- •
Update Vue package index:
typescriptexport * from './components/<component-name>';
- •
Verify React package already exports component
Phase 5: Update CHANGELOG
Add entry under [Unreleased]:
markdown
### Added
- `@lumx/vue`:
- Create the `<Component>` component
### Changed
- `@lumx/core`:
- Moved `<Component>` from `@lumx/react`
Phase 6: Verification
- •
Run tests:
bashyarn test packages/lumx-core/src/js/components/<Component> yarn test packages/lumx-react/src/components/<component> yarn test packages/lumx-vue/src/components/<component>
- •
Build packages:
bashyarn build:core yarn build:react yarn build:vue
- •
Visual verification in Storybook:
- •Verify React stories render correctly
- •Verify Vue stories render correctly
- •Test all variants and states
Key Patterns to Follow
Core Component Structure
typescript
import 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>
);
};
React Wrapper Structure
typescript
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/...';
export interface ComponentProps extends GenericProps, Omit<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,
});
});
Vue Wrapper Structure
typescript
import { computed, defineComponent, useAttrs } from 'vue';
import {
Component as ComponentUI,
type ComponentProps as UIProps,
CLASSNAME,
COMPONENT_NAME,
DEFAULT_PROPS,
} from '@lumx/core/js/components/Component';
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' | 'inputRef'>;
export const emitSchema = {
change: (/* params */) => /* validation */,
};
export { CLASSNAME, COMPONENT_NAME, DEFAULT_PROPS };
const Component = defineComponent(
(props: ComponentProps, { emit, slots }) => {
const attrs = useAttrs();
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 () => {
// Use JSX rendering
return (
<ComponentUI
{...otherProps.value}
className={props.class}
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;
Common Pitfalls
- •Don't use
Children.count()in core - This is React-specific - •Always use functional calls in core -
InputLabel({ ... })not<InputLabel ... /> - •Map children to label - React uses
children, core useslabel - •Include inputId in props - Wrappers generate it, core requires it
- •Add stopImmediatePropagation - Prevent event bubbling in Vue wrapper
- •Use JSX in Vue wrapper -
return (<Component />)not function calls - •Set correct component name - Vue:
'LumxComponent', not'Component' - •Handle readOnly correctly - Use
isAnyDisabled.valuenotaria-disabled
Reference Components
- •Checkbox: Full implementation with intermediate state
- •Switch: Recently migrated, good reference for binary components
- •Button: Good example of event handling with stopImmediatePropagation
Files Created/Modified Checklist
Core
- •
/packages/lumx-core/src/js/components/<Component>/index.tsx - •
/packages/lumx-core/src/js/components/<Component>/Tests.ts - •
/packages/lumx-core/src/js/components/<Component>/Stories.ts
React
- •
/packages/lumx-react/src/components/<component>/<Component>.tsx(modified) - •
/packages/lumx-react/src/components/<component>/<Component>.test.tsx(modified) - •
/packages/lumx-react/src/components/<component>/<Component>.stories.tsx(modified)
Vue
- •
/packages/lumx-vue/src/components/<component>/<Component>.tsx - •
/packages/lumx-vue/src/components/<component>/<Component>.test.ts - •
/packages/lumx-vue/src/components/<component>/<Component>.stories.ts - •
/packages/lumx-vue/src/components/<component>/Stories/<Component>Default.vue - •
/packages/lumx-vue/src/components/<component>/index.ts - •
/packages/lumx-vue/src/index.ts(add export)
Documentation
- •
/CHANGELOG.md(add entry under Unreleased)
Success Criteria
- • All core tests pass
- • All React tests pass (including new core tests)
- • All Vue tests pass (including new core tests)
- • All packages build successfully
- • React Storybook stories render correctly
- • Vue Storybook stories render correctly
- • React API is backward compatible
- • CHANGELOG is updated