AgentSkillsCN

migrate-component

将仅限 React 的组件迁移至共享核心/React/Vue 架构

SKILL.md
--- frontmatter
name: migrate-component
description: Migrate a React-only component to the shared core/React/Vue architecture
argument-hint: [component-name]
allowed-tools: Read, Write, Edit, Bash(yarn test:*), Bash(yarn build:*), Bash(mkdir:*), Bash(ls:*), Glob, Grep, Task, TodoWrite

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:

  1. The component exists in @lumx/react and is fully functional
  2. The component has existing tests and stories
  3. A reference component (like Checkbox) has already been migrated and can serve as a pattern

Migration Steps

Phase 1: Create Core Component

  1. Create core component directory:

    code
    packages/lumx-core/src/js/components/<ComponentName>/
    ├── index.tsx
    ├── Tests.ts
    └── Stories.ts
    
  2. Extract UI logic to index.tsx:

    • Change children prop to label: JSXElement (framework-agnostic)
    • Add required inputId: string prop (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
  3. 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
  4. Create Stories.ts:

    • Export setup() function returning story configurations
    • Create stories: Default, with variations, Disabled

Phase 2: Update React Wrapper

  1. Refactor React component:

    • Import UI component from core
    • Transform into thin wrapper using forwardRef
    • Use hooks: useId, useTheme, useDisableStateProps, useMergeRefs
    • Map childrenlabel for core component
    • Call UI({ ... }) instead of rendering JSX
    • Maintain backward compatibility
  2. Update React tests:

    • Import and run BaseComponentTests from core
    • Keep only React-specific tests (ref forwarding, theme context)
    • Create adapter to map props for core tests
  3. Update React stories:

    • Import setup from core stories
    • Add framework-specific decorators
    • Re-export stories

Phase 3: Create Vue Wrapper

  1. Create Vue component structure:

    code
    packages/lumx-vue/src/components/<component-name>/
    ├── <Component>.tsx
    ├── <Component>.test.ts
    ├── <Component>.stories.ts
    ├── Stories/
    │   └── <Component>Default.vue
    └── index.ts
    
  2. Create Vue wrapper (<Component>.tsx):

    • Use defineComponent with render function
    • Use composables: useTheme, useId, useDisableStateProps
    • Support both label prop 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
  3. Create Vue tests:

    • Import and run core tests
    • Add Vue-specific tests (emit events, disabled states)
    • Use @testing-library/vue
  4. Create Vue stories:

    • Import core story setup
    • Use withRender decorator
    • Disable isChecked control (managed internally)
  5. Create story template (.vue file):

    • Internal state management with ref
    • Use useAttrsWithoutHandlers
    • Handle change events
  6. Create index.ts:

    • Export component, props, and constants

Phase 4: Update Package Exports

  1. Update Vue package index:

    typescript
    export * from './components/<component-name>';
    
  2. 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

  1. Run tests:

    bash
    yarn test packages/lumx-core/src/js/components/<Component>
    yarn test packages/lumx-react/src/components/<component>
    yarn test packages/lumx-vue/src/components/<component>
    
  2. Build packages:

    bash
    yarn build:core
    yarn build:react
    yarn build:vue
    
  3. 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

  1. Don't use Children.count() in core - This is React-specific
  2. Always use functional calls in core - InputLabel({ ... }) not <InputLabel ... />
  3. Map children to label - React uses children, core uses label
  4. Include inputId in props - Wrappers generate it, core requires it
  5. Add stopImmediatePropagation - Prevent event bubbling in Vue wrapper
  6. Use JSX in Vue wrapper - return (<Component />) not function calls
  7. Set correct component name - Vue: 'LumxComponent', not 'Component'
  8. Handle readOnly correctly - Use isAnyDisabled.value not aria-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