UNPKG

@ffsm/compositor

Version:

A collection of declarative React utility components for simplified component composition, conditional rendering, and prop management - making React UI development more maintainable and expressive.

836 lines (625 loc) 24.8 kB
# @ffsm/compositor `@ffsm/compositor` is a collection of utility components that make React component composition more declarative and maintainable. These components solve common UI patterns in a consistent way, reducing boilerplate and making code more readable. ## Installation ```bash # Using npm npm install @ffsm/compositor # Using yarn yarn add @ffsm/compositor # Using pnpm pnpm add @ffsm/compositor ``` ## Features Overview - **Declarative Composition**: Replace imperative logic with declarative components - **Prop Injection**: Easily manage and propagate props through component hierarchies - **Conditional Rendering**: Simplify conditional UI patterns - **Type Safety**: Full TypeScript support with proper generic types - **Small Footprint**: Lightweight implementation with minimal dependencies - **Customizable**: Flexible API supporting various composition patterns ## When to Use The compositor library is particularly useful when: - Building component libraries with consistent composition patterns - Managing complex conditional rendering logic - Creating reusable layout components - Implementing slot-based component architectures - Reducing boilerplate in React applications ## Component Overview | Component | Purpose | When to Use | | ------------ | ------------------------------------- | ----------------------------------------------------------------------------------- | | `AsInstance` | Prop merging for a single element | When you need to extend an element with additional props | | `AsArray` | Batch operations on multiple children | When working with collections of elements that need shared props or transformations | | `AsNode` | Conditional rendering (if) | When you need to conditionally render content based on a single condition | | `AsSlot` | Content projection into wrappers | When implementing component composition with slots or insertion points | | `Condition` | Conditional rendering (if/else) | When you need to choose between two rendering paths | | `Empty` | Empty state handling | When working with potentially empty data or content | ## Components ### AsInstance Component `AsInstance` is a utility component that helps with prop composition by merging additional props with a React element's existing props. #### Features - **Prop Merging**: Combines specified props with a React element's existing props - **Safe Handling**: Gracefully handles non-element children - **Simple API**: Straightforward usage pattern with minimal boilerplate - **Type Safety**: Full TypeScript support with proper type definitions #### Basic Usage ```jsx import { AsInstance } from '@ffsmio/compositor'; function App() { return ( <AsInstance className="enhanced" data-testid="submit-btn"> <button onClick={handleClick}>Submit</button> </AsInstance> ); } ``` This renders the button with its original `onClick` handler plus the new `className` and `data-testid` props. #### Prop Merging Behavior When both the child element and the `AsInstance` wrapper specify the same prop, the wrapper's prop takes precedence: ```jsx <AsInstance className="override-class"> <div className="original-class">Content</div> </AsInstance> ``` This renders: `<div className="override-class">Content</div>` #### Working with Non-Element Children If you pass a non-element child (like plain text, numbers, null, or undefined), `AsInstance` returns it unchanged: ```jsx <AsInstance className="will-be-ignored">Just some plain text</AsInstance> ``` This renders: `Just some plain text` #### Use Cases - **Applying theme props**: Add theme-related props to components - **Adding accessibility attributes**: Enhance components with aria attributes - **Component composition**: Create higher-order components that add behavior - **Dynamic props**: Add conditional props based on application state #### API Reference ##### Props | Prop | Type | Description | | ---------- | --------- | ------------------------------------------------ | | `children` | ReactNode | Child element to receive merged props | | `...rest` | any | Additional props to merge with the child element | #### Implementation Notes - Uses React's `cloneElement` under the hood for prop merging - Performs proper type checking with `isValidElement` before attempting to clone - Preserves the child's original component identity and ref ### AsArray Component `AsArray` is a utility component that makes working with collections of React children more powerful by providing filtering and transformation capabilities. #### Features - **Prop Inheritance**: Pass props to all children at once - **Filtering**: Include only specific children using a filter function - **Transformation**: Transform children with a mapping function - **Key Management**: Automatically handles React's key requirements #### Basic Usage ```jsx import { AsArray } from '@ffsmio/compositor'; function App() { return ( <AsArray className="shared-class" data-testid="group"> <button>First Button</button> <button>Second Button</button> <button>Third Button</button> </AsArray> ); } ``` This renders three buttons, each with the `className="shared-class"` and `data-testid="group"` props. #### Filtering Children Use the `filter` prop to selectively include children: ```jsx <AsArray filter={(child, index) => { // Only include even-indexed children return index % 2 === 0; }} className="even-only" > <div>Item 0</div> <div>Item 1</div> <div>Item 2</div> <div>Item 3</div> </AsArray> ``` #### Transforming Children Use the `map` prop to transform children: ```jsx <AsArray map={(child, index) => { // Add index to each child's content if (React.isValidElement(child)) { return React.cloneElement( child, child.props, `${child.props.children} (${index})` ); } return child; }} > <li>Apple</li> <li>Banana</li> <li>Cherry</li> </AsArray> ``` Renders: - Apple (0) - Banana (1) - Cherry (2) #### API Reference ##### Props | Prop | Type | Description | | ---------- | ---------------------------------------------- | --------------------------------------- | | `children` | ReactNode | Child elements to process | | `filter` | (child: ReactNode, index: number) => boolean | Optional function to filter children | | `map` | (child: ReactNode, index: number) => ReactNode | Optional function to transform children | | `...rest` | any | Additional props passed to all children | #### Notes - All children are rendered inside an `AsInstance` component, which handles proper prop merging - The component internally uses React's `Children.toArray()` for stable keys and array operations - When filtering, children are excluded completely rather than rendered conditionally ### AsNode Component `AsNode` is a declarative conditional rendering component that simplifies the common pattern of rendering content only when a condition is met. #### Features - **Simplified Conditional Rendering**: Replaces ternary expressions and `&&` patterns - **Declarative API**: Makes conditional rendering more readable - **Function Conditions**: Supports functions and async functions as conditions - **Falsy Value Handling**: Optional strict falsy checking for empty strings, zero, etc. - **Prop Forwarding**: Passes additional props to rendered children #### Basic Usage ```jsx import { AsNode } from '@ffsmio/compositor'; function UserSection({ user }) { return ( <AsNode of={user}> <div className="user-info"> <h2>{user.name}</h2> <p>{user.email}</p> </div> </AsNode> ); } ``` This renders the user info div only when `user` exists (is not `undefined` or `false`). **Using Function Conditions** You can use a function as the condition, which is useful for dynamic evaluations: ```jsx <AsNode of={(props) => userService.hasPermission('admin')}> <AdminPanel /> </AsNode> ``` The function receives all props passed to AsNode, allowing for contextual conditions. **Async Conditions** AsNode also supports async functions for conditions that need to be resolved: ```jsx <AsNode of={async () => await checkUserSubscription()}> <PremiumContent /> </AsNode> ``` #### Comparing with Traditional Conditional Rendering Traditional approach: ```jsx function UserSection({ user }) { return user ? ( <div className="user-info"> <h2>{user.name}</h2> <p>{user.email}</p> </div> ) : null; } ``` With `AsNode`: ```jsx function UserSection({ user }) { return ( <AsNode of={user}> <div className="user-info"> <h2>{user.name}</h2> <p>{user.email}</p> </div> </AsNode> ); } ``` #### Enhanced Falsy Checking By default, `AsNode` only treats `undefined` and `false` as falsy. To extend this to all JavaScript falsy values (empty strings, 0, NaN, null), use the `falsy` prop: ```jsx <AsNode of={searchResults.length} falsy> <SearchResultsList results={searchResults} /> </AsNode> ``` This will only render the list when there are actual results. #### Passing Props to Children `AsNode` uses `AsInstance` internally, so any additional props will be passed to the children: ```jsx <AsNode of={isAdmin} className="admin-panel" data-testid="admin-section"> <AdminControls /> </AsNode> ``` #### API Reference ##### Props | Prop | Type | Default | Description | | ---------- | --------- | --------- | ----------------------------------------------------------------------------------------------- | | `children` | ReactNode | required | Content to conditionally render | | `of` | unknown | undefined | The condition that determines if children render. Maybe using as a function or promise function | | `falsy` | boolean | false | When true, any falsy value prevents rendering | | `...rest` | any | - | Additional props passed to children through `AsInstance` | #### Use Cases - Conditional rendering based on user permissions - Showing components only when data is available - Feature flags and toggles - Simplifying complex conditional rendering logic - Dynamic conditions that depend on runtime state or API calls ### AsSlot Component `AsSlot` implements a slot-based composition pattern for React, allowing children to be rendered within a specified outlet component or through a render function. #### Features - **Slot-Based Composition**: Inject content into wrapper components - **Flexible API**: Use either component outlets or render functions - **Prop Forwarding**: Pass props to both the outlet and the content - **Ref Handling**: Properly forwards refs between components #### Basic Usage ```jsx import { AsSlot } from '@ffsmio/compositor'; import { Card } from './components'; function UserProfile({ user }) { return ( <AsSlot outlet={<Card />}> <h2>{user.name}</h2> <p>{user.bio}</p> </AsSlot> ); } ``` This renders the user profile content inside the `Card` component. #### Using Outlet Props You can pass specific props to the outlet component using `outletProps`: ```jsx <AsSlot outlet={<Panel />} outletProps={{ title: 'Settings', collapsible: true, defaultExpanded: true, }} > <SettingsForm /> </AsSlot> ``` #### Render Function Pattern For more dynamic scenarios, use a render function as the outlet: ```jsx <AsSlot outlet={(props) => ( <Modal isOpen={isModalOpen} onClose={handleClose} {...props} /> )} className="modal-content" > <h2>Confirm Deletion</h2> <p>This action cannot be undone.</p> <div className="button-group"> <button onClick={handleConfirm}>Delete</button> <button onClick={handleCancel}>Cancel</button> </div> </AsSlot> ``` #### Passing Props to Children Additional props are passed to the children via `AsInstance`: ```jsx <AsSlot outlet={<Card />} className="highlighted" data-testid="user-card"> <UserProfile /> </AsSlot> ``` #### Comparison with Traditional Composition Traditional approach: ```jsx <Card> <div className="highlighted" data-testid="user-card"> <UserProfile /> </div> </Card> ``` With `AsSlot`: ```jsx <AsSlot outlet={<Card />} className="highlighted" data-testid="user-card"> <UserProfile /> </AsSlot> ``` #### API Reference ##### Props | Prop | Type | Description | | ------------- | ---------------------------------- | -------------------------------------------------- | | `children` | ReactNode | Content to render inside the outlet | | `outlet` | ReactNode \| RenderFunction<Props> | Component or function to wrap children | | `outletProps` | ObjectProps | Props to pass to the outlet component | | `...rest` | ObjectProps | Additional props passed to children via AsInstance | ##### Type Definitions ```typescript type ObjectProps = Record<string, any>; type RenderFunction<Props> = (props: Props) => ReactNode; ``` #### Use Cases - Creating composite UI patterns like cards, panels, and dialogs - Building component libraries with consistent wrappers - Implementing layout components with customizable content areas - Creating higher-order components with enhanced behavior ### Condition Component `Condition` is a declarative conditional rendering component that simplifies rendering different content based on conditions, with support for fallback content. #### Features - **If/Else Pattern**: Renders either main content or fallback content - **Declarative API**: Makes conditional rendering more readable - **Function Conditions**: Supports functions and async functions for dynamic evaluation - **Falsy Value Handling**: Optional strict falsy checking - **Prop Forwarding**: Passes props to whichever content is rendered #### Basic Usage ```jsx import { Condition } from '@ffsmio/compositor'; function ProfileSection({ user, isLoading }) { return ( <Condition when={!isLoading && user} fallback={<LoadingSpinner />}> <UserProfile data={user} /> </Condition> ); } ``` This renders the `UserProfile` when a user exists and it's not loading, or a `LoadingSpinner` otherwise. **Using Function Conditions** You can use a function as the condition, which is useful for dynamic evaluations: ```jsx <Condition when={(props) => userService.hasPermission('admin')} fallback={<AccessDenied />} > <AdminPanel /> </Condition> ``` The function receives all props passed to Condition, allowing for contextual conditions. **Async Conditions** Condition also supports async functions for conditions that need to be resolved: ```jsx <Condition when={async () => await checkUserSubscription()} fallback={<SubscribePrompt />} > <PremiumContent /> </Condition> ``` #### Enhanced Falsy Checking By default, `Condition` only treats `undefined` and `false` as falsy. To extend this to all JavaScript falsy values (empty strings, 0, NaN, null), use the `falsy` prop: ```jsx <Condition when={searchResults.length} falsy fallback={<EmptyState message="No results found" />} > <SearchResults items={searchResults} /> </Condition> ``` #### Passing Props to Rendered Content `Condition` uses `AsInstance` internally, so any additional props will be passed to whichever content is rendered: ```jsx <Condition when={isAuthenticated} fallback={<LoginPage />} className="main-content" data-testid="content-section" > <Dashboard /> </Condition> ``` #### No Fallback If you don't provide a fallback, nothing is rendered when the condition is falsy: ```jsx <Condition when={showBanner}> <AnnouncementBanner message={bannerText} /> </Condition> ``` #### Comparing with Traditional Conditional Rendering Traditional approach: ```jsx function ProfileSection({ user, isLoading }) { return !isLoading && user ? ( <UserProfile data={user} className="profile-section" /> ) : ( <LoadingSpinner className="profile-section" /> ); } ``` With `Condition`: ```jsx function ProfileSection({ user, isLoading }) { return ( <Condition when={!isLoading && user} fallback={<LoadingSpinner />} className="profile-section" > <UserProfile data={user} /> </Condition> ); } ``` #### API Reference ##### Props | Prop | Type | Default | Description | | ---------- | --------- | --------- | -------------------------------------------------------------------------------------------------- | | `children` | ReactNode | required | Content to display when condition is truthy | | `when` | unknown | undefined | The condition that determines which content to show, maybe using as a function or promise function | | `falsy` | boolean | false | When true, any falsy value triggers fallback | | `fallback` | ReactNode | undefined | Content to display when condition is falsy | | `...rest` | any | - | Props passed to whichever content is rendered | #### Use Cases - Toggling between loading states and loaded content - Showing different UI based on user permissions or roles - Displaying error states when operations fail - Implementing feature flags or experimental features - Dynamic conditions that depend on runtime state or API calls ### Empty Component `Empty` is a utility component that simplifies handling empty or undefined children by rendering fallback content when needed. #### Features - **Empty State Handling**: Automatically detects undefined or false children - **Fallback Content**: Provides alternative content when children are empty - **Falsy Value Detection**: Optional strict checking for all falsy values - **Prop Forwarding**: Passes props to whichever content is rendered #### Basic Usage ```jsx import { Empty } from '@ffsmio/compositor'; function UserDetails({ user }) { return ( <Empty fallback={<p>No user information available</p>}> {user && ( <div> <h2>{user.name}</h2> <p>{user.email}</p> </div> )} </Empty> ); } ``` This renders the user details when they exist, or the fallback message when `user` is falsy. #### Enhanced Falsy Checking By default, `Empty` only treats `undefined` and `false` as empty. To extend this to all JavaScript falsy values (empty strings, 0, NaN, null), use the `falsy` prop: ```jsx <Empty fallback={<NoResultsView />} falsy> {searchResults.length && <ResultsList results={searchResults} />} </Empty> ``` #### Passing Props `Empty` uses `AsInstance` internally, so any additional props will be passed to whichever content is rendered: ```jsx <Empty fallback={<EmptyState />} className="content-container" data-testid="results-area" > {data} </Empty> ``` #### Comparing with Traditional Patterns Traditional approach: ```jsx function MessageDisplay({ message }) { return message ? ( <div className="message">{message}</div> ) : ( <div className="message">No message available</div> ); } ``` With `Empty`: ```jsx function MessageDisplay({ message }) { return ( <Empty fallback="No message available" className="message"> {message} </Empty> ); } ``` #### API Reference ##### Props | Prop | Type | Default | Description | | ---------- | --------- | --------- | --------------------------------------------- | | `children` | ReactNode | - | The primary content to render if not empty | | `fallback` | ReactNode | undefined | Content to display when children are empty | | `falsy` | boolean | false | When true, any falsy value triggers fallback | | `...rest` | any | - | Props passed to whichever content is rendered | #### Use Cases - Displaying placeholders when data is not available - Creating components with meaningful empty states - Building more resilient UI components - Simplifying conditional rendering in JSX ## Utilities ### `createEvent` The `createEvent` function creates custom events for the Compositor system with full support for event bubbling, propagation control, and default action prevention. #### Basic Usage ```typescript import { createEvent } from '@ffsmio/compositor'; // Create a basic event const myEvent = createEvent('button-click', { id: 'submit-button' }); // Use the event element.dispatchEvent(myEvent); ``` #### Type Safety with Generics ```typescript // Define a custom event type interface ClickEvent { name: string; value: { x: number; y: number; }; preventDefault(): void; stopPropagation(): void; } // Create a strongly-typed event const clickEvent = createEvent<ClickEvent>('click', { x: 100, y: 200 }); ``` #### Event Features Created events include: - **Event naming**: Associate a name with your event - **Custom payload**: Attach any value to your event - **Bubbling control**: Events bubble by default, configurable via constructor - **Cancellation**: Events can be cancelled using `preventDefault()` - **Propagation control**: Stop event propagation with `stopPropagation()` - **Target tracking**: Both original target and current target are tracked #### Event Properties and Methods | Property/Method | Description | | ------------------------ | -------------------------------------- | | `name` | Event name identifier | | `value` | Event payload data | | `target` | Original event target | | `currentTarget` | Current target in the propagation path | | `preventDefault()` | Prevents the default action | | `stopPropagation()` | Stops event propagation | | `isDefaultPrevented()` | Checks if default action was prevented | | `isPropagationStopped()` | Checks if propagation was stopped | ## Advanced Usage ### Composition Patterns Components can be composed to create more complex patterns: ```jsx <Condition when={hasData} fallback={<LoadingState />}> <AsArray filter={(item) => item.isVisible} className="data-item"> {data.map((item) => ( <DataItem key={item.id} {...item} /> ))} </AsArray> </Condition> ``` ### Custom Hooks Integration The components work well with custom hooks: ```jsx function useUserData() { const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); // Fetch logic... return { data, isLoading, error }; } function UserProfile() { const { data, isLoading, error } = useUserData(); return ( <Condition when={!isLoading} fallback={<LoadingSpinner />}> <Condition when={!error} fallback={<ErrorMessage error={error} />}> <UserCard user={data} /> </Condition> </Condition> ); } ``` ## Performance Considerations - All components are optimized for minimal re-renders - When using `AsArray` with large lists, consider memoizing filter and map functions - For deeply nested component trees, consider composition at appropriate levels rather than passing props through many layers ## Browser Support - Supports all modern browsers - IE11 compatible with appropriate polyfills - Works in both client-side and server-side rendering environments ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. 1. Fork the repository 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 3. Commit your changes (`git commit -m 'Add some amazing feature'`) 4. Push to the branch (`git push origin feature/amazing-feature`) 5. Open a Pull Request ## License This project is licensed under the MIT License - see the LICENSE file for details.