diff --git a/.size-limit.json b/.size-limit.json index 212fefb..9fa916f 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -1 +1 @@ -[{ "path": "dist/index.js", "limit": "800 B" }] +[{ "path": "dist/index.js", "limit": "850 B" }] diff --git a/CHANGELOG.md b/CHANGELOG.md index ab4349f..e8480e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](http://semver.org). +## 2.0.0 + +### Changed + +- renamed `fn` to `mapProps` + +### Added + +- `when` parameter to defer slot insertion until specified Effector events fire + +```tsx +const userLoaded = createEvent<{ id: number }>(); + +slotsApi.insert.into.Header({ + when: userLoaded, // Wait for event + mapProps: (slotProps, whenPayload) => ({ userId: whenPayload.id }), + component: (props) => , +}); + +userLoaded({ id: 123 }); // Component inserted now +``` + ## 1.1.0 ### Added diff --git a/README.md b/README.md index cacbe0c..35944c6 100644 --- a/README.md +++ b/README.md @@ -1,208 +1,251 @@ -# React Slots +

+ React Slots +

-Bring the power of slots to your React components effortlessly. +# React Slots -## Table of Contents +**Build extensible React components with slot-based architecture.** Define extension points where plugins and third-party code can inject content. -- [Motivation](#motivation) -- [How does this library solve this problem?](#how-does-this-library-solve-this-problem) -- [Example](#example) -- [Installation](#installation) -- [How-to Guides](#how-to-guides) - - [How to Pass Props to Components Inserted into a Slot](#how-to-pass-props-to-components-inserted-into-a-slot) - - [How to Insert Multiple Components into a Slot](#how-to-insert-multiple-components-into-a-slot) - - [How to Manage the Order of Components in a Slot](#how-to-manage-the-order-of-components-in-a-slot) -- [Community](#community) +## What are slots? -## Motivation +**Slots** are named extension points in a component where content can be injected from outside. -In modern React applications, building reusable and **flexible** components is key to scaling efficiently. However, as the complexity of components increases, the need for a slot-based architecture becomes apparent. The concept of slots, familiar to developers from frameworks like Svelte and Vue, allows for seamless content injection and greater customization of component behavior. But in React, this pattern isn’t natively supported and often leads to verbose or suboptimal solutions. +Vue example: -## How does this library solve this problem? +```vue + + -`react-slots` introduces a streamlined way to implement slots, bringing familiar concepts into the React ecosystem with minimal effort. It provides developers with a clear and consistent API to define and use slots, enhancing flexibility while reducing boilerplate code. The library ensures components remain decoupled, making it easier to manage nested or complex content structures. + + + + +``` -## Example +## The problem in React -This example demonstrates how to create and use slots in React using the `@grlt-hub/react-slots` library. +React doesn't have a built-in slot system. This creates challenges when building **extensible architectures** where different parts of your app (or plugins) need to inject content into predefined locations. -### Code Breakdown +### Example: Admin dashboard with plugins -1. **Creating Slot Identifiers** +You're building an admin dashboard. Plugins should be able to add widgets to the sidebar without modifying the core `Sidebar` component: -```ts -import { createSlots, createSlotIdentifier } from '@grlt-hub/react-slots'; +```tsx +// Sidebar.tsx - core component (shouldn't change when plugins are added) +export const Sidebar = () => ( + +); -const slots = { - Bottom: createSlotIdentifier(), -} as const; +// plugin-analytics/index.ts - separate package +// This plugin wants to add analytics widget to sidebar +// How??? πŸ€·β€β™‚οΈ ``` -We import `createSlots` and `createSlotIdentifier` from the library. Then, we define a slots object, where each key represents a unique slot. In this case, we create a slot named Bottom. +### Standard approaches are awkward -2. **Creating the Slot API** +- Collecting everything in parent component - tight coupling, parent must know all plugins +- Context with manual management - lots of boilerplate per extension point +- Passing render functions through props - verbose, non-intuitive API -```ts -const { slotsApi: footerSlots, Slots: FooterSlots } = createSlots(slots); -``` +## The solution -`createSlots` takes the `slots` object and returns two values: +**With `@grlt-hub/react-slots`, define extension points once and inject components from anywhere:** -- `slotsApi` (renamed to `footerSlots`): an API for managing slot content. -- `Slots` (renamed to `FooterSlots`): a component used to render the slot content in the specified location. +```tsx +// Sidebar.tsx - define the slot +import { createSlots, createSlotIdentifier } from '@grlt-hub/react-slots'; -3. **Defining the Footer Component** +export const { slotsApi, Slots } = createSlots({ + Widgets: createSlotIdentifier(), +} as const); -```tsx -const Footer = () => ( - +export const Sidebar = () => ( + ); -``` -Using `footerSlots.insert.into.Bottom`, we insert content into the `Bottom` slot. Here, we add a component that renders `World`. +// plugin-analytics/index.ts - inject from anywhere! +import { slotsApi } from './Sidebar'; -### Result +slotsApi.insert.into.Widgets({ + component: () => , +}); -After executing the code, the rendered output will be: +// plugin-user-stats/index.ts - another plugin +import { slotsApi } from './Sidebar'; -```html -
- Hello - World -
+slotsApi.insert.into.Widgets({ + component: () => , +}); ``` -This way, the `@grlt-hub/react-slots` library provides an efficient way to define and use slots in React components, making content injection simple and flexible. +### Result + +```tsx + +``` + +No props drilling, no boilerplate - just define slots and inject content from anywhere in your codebase. ## Installation ```sh npm i @grlt-hub/react-slots # or -yarn add @grlt-hub/react-slots -# or pnpm add @grlt-hub/react-slots +# or +bun add @grlt-hub/react-slots +# or +yarn add @grlt-hub/react-slots ``` -## How-to Guides +TypeScript types are included out of the box. -### How to Pass Props to Components Inserted into a Slot +### Peer dependencies -In this guide, we'll walk through how to define and pass props to components inserted into a slot using `@grlt-hub/react-slots`. +- `react` ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 +- `effector` 23 +- `effector-react` 23 +- `nanoid` \* -#### Step 1: Define a Slot with Props +## Quick Start -You can specify the props a slot should accept by providing a type to `createSlotIdentifier`. For example, if you want a slot that requires a text prop, you can define it like this: +Here's a minimal working example: -```ts -import { createSlotIdentifier } from '@grlt-hub/react-slots'; +```tsx +import { createSlots, createSlotIdentifier } from '@grlt-hub/react-slots'; -const slots = { - Bottom: createSlotIdentifier<{ text: string }>(), -} as const; -``` +// 1. Create slots +const { slotsApi, Slots } = createSlots({ + Footer: createSlotIdentifier(), +} as const); + +// 2. Use slot in your component +const App = () => ( +
+

My App

+ +
+); -This type definition ensures that any usage of `` must include a `text` prop. +// 3. Insert content into the slot +slotsApi.insert.into.Footer({ + component: () =>

Β© 1955–1985–2015 Outatime Corp.

, +}); -#### Step 2: Use the Slot in Your Component +// Result: +//
+//

My App

+//

Β© 1955–1985–2015 Outatime Corp.

+//
+``` + +## How-to Guides -When you use the slot component in your layout, you must pass the required props directly: +### Pass props to inserted components ```tsx -const Footer = () => ( -
- Footer content - -
-); -``` +// Define slot with typed props +const { slotsApi, Slots } = createSlots({ + UserPanel: createSlotIdentifier<{ userId: number }>(), +} as const); -The `text` prop passed here will be provided to any component inserted into the `Bottom` slot. +// Use in component +; -#### Step 3: Insert a Component into the Slot +// Insert component - receives props automatically +slotsApi.insert.into.UserPanel({ + component: (props) => , +}); +``` -You use `footerSlots.insert.into.Bottom` to insert a component. The component will automatically receive the props passed to ``: +### Transform props with `mapProps` ```tsx -footerSlots.insert.into.Bottom({ - fn: ({ text }) => ({ doubleText: `${text} ${text}` }), - component: ({ doubleText }) =>

{doubleText}

, +const { slotsApi, Slots } = createSlots({ + UserPanel: createSlotIdentifier<{ userId: number }>(), +} as const); + +; + +slotsApi.insert.into.UserPanel({ + // Transform userId into userName and isAdmin before passing to component + mapProps: (slotProps) => ({ + userName: getUserName(slotProps.userId), + isAdmin: checkAdmin(slotProps.userId), + }), + component: (props) => , }); ``` -- `fn`: This function is optional. If provided, it receives the props from `` (e.g.,` { text }`) and allows you to transform them before passing them to `component`. In the example above, `fn` takes `text` and creates a new prop `doubleText`, which repeats the `text` value twice. -- **Without** `fn`: If `fn` is not provided, the props from `` are passed directly to component without any transformation, one-to-one. -- `component`: This function receives either the transformed props (if `fn` is used) or the original props and renders the component accordingly. - -This flexibility allows you to choose whether to modify props or pass them through unchanged, depending on your use case. - -### How to Insert Multiple Components into a Slot +### Control rendering order -Inserting multiple components into a slot is straightforward. You can call `footerSlots.insert.into.Bottom` multiple times to add different components. The components will be added in the order in which they are inserted. - -#### Example - -Here's how you can insert multiple components into the `Bottom` slot: +Components are inserted in any order, but rendered according to `order` value (lower numbers first): ```tsx -footerSlots.insert.into.Bottom({ - component: () =>

First Component

, +// This is inserted first, but will render second +slotsApi.insert.into.Sidebar({ + component: () => , + order: 2, }); -footerSlots.insert.into.Bottom({ - component: () =>

Second Component

, +// This is inserted second, but will render first +slotsApi.insert.into.Sidebar({ + component: () => , + order: 1, }); -``` - -In this example: - -- The first call to `footerSlots.insert.into.Bottom` inserts a component that renders `

First Component

`. -- The second call inserts a component that renders `

Second Component

`. - -The components will appear in the order they are inserted, so the rendered output will look like this: -```html -
First Component Second Component
+// Result: +// <> +// ← order: 1 +// ← order: 2 +// ``` -### How to Manage the Order of Components in a Slot +**Note:** Components with the same `order` value keep their insertion order and all of them are rendered. -You can control the order in which components are rendered within a slot using the optional `order` property. By default, components are added in the order they are inserted. However, you can specify a custom order to rearrange them. +### Defer insertion until event fires -#### Example - -Let's build on the previous example and introduce the order property: +Wait for data to load before inserting component. The component won't render until the event fires: ```tsx -footerSlots.insert.into.Bottom({ - component: () =>

First Component

, -}); - -footerSlots.insert.into.Bottom({ - component: () =>

Second Component

, - order: 0, +import { createEvent } from 'effector'; + +const userLoaded = createEvent<{ id: number; name: string }>(); + +// Component will be inserted only after userLoaded fires +slotsApi.insert.into.Header({ + when: userLoaded, + mapProps: (slotProps, whenPayload) => ({ + userId: whenPayload.id, + userName: whenPayload.name, + }), + component: (props) => , }); -``` - -- In this case, the first call inserts `

First Component

` without an `order` property, so it gets the default position. -- The second call inserts `

Second Component

` and specifies `order: 0`. This causes the "Second Component" to be rendered before the "First Component". -With the order property applied, the rendered output will look like this: +// Component is not rendered yet... -```html -
Second Component First Component
+// Later, when data arrives: +userLoaded({ id: 123, name: 'John' }); // NOW the component is inserted and rendered ``` -#### How the `order` Property Works - -- **Type**: `order` is always a number. -- **Default Behavior**: If `order` is not provided, the components are rendered in the order they are inserted. -- **Custom Order**: Components with a lower `order` value are rendered before those with a higher value. If multiple components have the same `order` value, they maintain the order of insertion. +**Note:** You can pass an array of events `when: [event1, event2]` - component inserts when **any** of them fires. Use [once](https://patronum.effector.dev/operators/once/) from `patronum` if you need one-time insertion. ## Community -- [Discord](https://discord.gg/Q4DFKnxp) - [Telegram](https://t.me/grlt_hub_app_compose) diff --git a/logo.webp b/logo.webp new file mode 100644 index 0000000..e988259 Binary files /dev/null and b/logo.webp differ diff --git a/package-lock.json b/package-lock.json index f63d78d..1c6272c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,26 +1,26 @@ { "name": "@grlt-hub/react-slots", - "version": "1.1.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@grlt-hub/react-slots", - "version": "1.1.0", + "version": "2.0.0", "license": "MIT", "devDependencies": { "@rslib/core": "0.17.0", "@size-limit/preset-small-lib": "11.2.0", + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "clean-publish": "6.0.1", "prettier": "3.6.2", "prettier-plugin-organize-imports": "4.3.0", "size-limit": "11.2.0", "tslib": "2.8.1", "typescript": "5.9.3", - "vitest": "4.0.6" + "vitest": "4.0.7" }, "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "effector": "23", "effector-react": "23", "nanoid": "*", @@ -1407,23 +1407,23 @@ "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } }, "node_modules/@vitest/expect": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.6.tgz", - "integrity": "sha512-5j8UUlBVhOjhj4lR2Nt9sEV8b4WtbcYh8vnfhTNA2Kn5+smtevzjNq+xlBuVhnFGXiyPPNzGrOVvmyHWkS5QGg==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.7.tgz", + "integrity": "sha512-jGRG6HghnJDjljdjYIoVzX17S6uCVCBRFnsgdLGJ6CaxfPh8kzUKe/2n533y4O/aeZ/sIr7q7GbuEbeGDsWv4Q==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.6", - "@vitest/utils": "4.0.6", + "@vitest/spy": "4.0.7", + "@vitest/utils": "4.0.7", "chai": "^6.0.1", "tinyrainbow": "^3.0.3" }, @@ -1432,13 +1432,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.6.tgz", - "integrity": "sha512-3COEIew5HqdzBFEYN9+u0dT3i/NCwppLnO1HkjGfAP1Vs3vti1Hxm/MvcbC4DAn3Szo1M7M3otiAaT83jvqIjA==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.7.tgz", + "integrity": "sha512-OsDwLS7WnpuNslOV6bJkXVYVV/6RSc4eeVxV7h9wxQPNxnjRvTTrIikfwCbMyl8XJmW6oOccBj2Q07YwZtQcCw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.6", + "@vitest/spy": "4.0.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.19" }, @@ -1459,9 +1459,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.6.tgz", - "integrity": "sha512-4vptgNkLIA1W1Nn5X4x8rLJBzPiJwnPc+awKtfBE5hNMVsoAl/JCCPPzNrbf+L4NKgklsis5Yp2gYa+XAS442g==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.7.tgz", + "integrity": "sha512-YY//yxqTmk29+/pK+Wi1UB4DUH3lSVgIm+M10rAJ74pOSMgT7rydMSc+vFuq9LjZLhFvVEXir8EcqMke3SVM6Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1472,13 +1472,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.6.tgz", - "integrity": "sha512-trPk5qpd7Jj+AiLZbV/e+KiiaGXZ8ECsRxtnPnCrJr9OW2mLB72Cb824IXgxVz/mVU3Aj4VebY+tDTPn++j1Og==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.7.tgz", + "integrity": "sha512-orU1lsu4PxLEcDWfjVCNGIedOSF/YtZ+XMrd1PZb90E68khWCNzD8y1dtxtgd0hyBIQk8XggteKN/38VQLvzuw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.6", + "@vitest/utils": "4.0.7", "pathe": "^2.0.3" }, "funding": { @@ -1486,13 +1486,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.6.tgz", - "integrity": "sha512-PaYLt7n2YzuvxhulDDu6c9EosiRuIE+FI2ECKs6yvHyhoga+2TBWI8dwBjs+IeuQaMtZTfioa9tj3uZb7nev1g==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.7.tgz", + "integrity": "sha512-xJL+Nkw0OjaUXXQf13B8iKK5pI9QVtN9uOtzNHYuG/o/B7fIEg0DQ+xOe0/RcqwDEI15rud1k7y5xznBKGUXAA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.6", + "@vitest/pretty-format": "4.0.7", "magic-string": "^0.30.19", "pathe": "^2.0.3" }, @@ -1501,9 +1501,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.6.tgz", - "integrity": "sha512-g9jTUYPV1LtRPRCQfhbMintW7BTQz1n6WXYQYRQ25qkyffA4bjVXjkROokZnv7t07OqfaFKw1lPzqKGk1hmNuQ==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.7.tgz", + "integrity": "sha512-FW4X8hzIEn4z+HublB4hBF/FhCVaXfIHm8sUfvlznrcy1MQG7VooBgZPMtVCGZtHi0yl3KESaXTqsKh16d8cFg==", "dev": true, "license": "MIT", "funding": { @@ -1511,13 +1511,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.6.tgz", - "integrity": "sha512-bG43VS3iYKrMIZXBo+y8Pti0O7uNju3KvNn6DrQWhQQKcLavMB+0NZfO1/QBAEbq0MaQ3QjNsnnXlGQvsh0Z6A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.7.tgz", + "integrity": "sha512-HNrg9CM/Z4ZWB6RuExhuC6FPmLipiShKVMnT9JlQvfhwR47JatWLChA6mtZqVHqypE6p/z6ofcjbyWpM7YLxPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.6", + "@vitest/pretty-format": "4.0.7", "tinyrainbow": "^3.0.3" }, "funding": { @@ -1606,8 +1606,8 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", @@ -2249,19 +2249,19 @@ } }, "node_modules/vitest": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.6.tgz", - "integrity": "sha512-gR7INfiVRwnEOkCk47faros/9McCZMp5LM+OMNWGLaDBSvJxIzwjgNFufkuePBNaesGRnLmNfW+ddbUJRZn0nQ==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.7.tgz", + "integrity": "sha512-xQroKAadK503CrmbzCISvQUjeuvEZzv6U0wlnlVFOi5i3gnzfH4onyQ29f3lzpe0FresAiTAd3aqK0Bi/jLI8w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.6", - "@vitest/mocker": "4.0.6", - "@vitest/pretty-format": "4.0.6", - "@vitest/runner": "4.0.6", - "@vitest/snapshot": "4.0.6", - "@vitest/spy": "4.0.6", - "@vitest/utils": "4.0.6", + "@vitest/expect": "4.0.7", + "@vitest/mocker": "4.0.7", + "@vitest/pretty-format": "4.0.7", + "@vitest/runner": "4.0.7", + "@vitest/snapshot": "4.0.7", + "@vitest/spy": "4.0.7", + "@vitest/utils": "4.0.7", "debug": "^4.4.3", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", @@ -2289,10 +2289,10 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.6", - "@vitest/browser-preview": "4.0.6", - "@vitest/browser-webdriverio": "4.0.6", - "@vitest/ui": "4.0.6", + "@vitest/browser-playwright": "4.0.7", + "@vitest/browser-preview": "4.0.7", + "@vitest/browser-webdriverio": "4.0.7", + "@vitest/ui": "4.0.7", "happy-dom": "*", "jsdom": "*" }, diff --git a/package.json b/package.json index e6c8256..5eb9172 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@grlt-hub/react-slots", - "version": "1.1.0", + "version": "2.0.0", "type": "module", "private": false, "main": "dist/index.js", @@ -12,16 +12,16 @@ "devDependencies": { "@rslib/core": "0.17.0", "@size-limit/preset-small-lib": "11.2.0", + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "clean-publish": "6.0.1", "prettier": "3.6.2", "prettier-plugin-organize-imports": "4.3.0", "size-limit": "11.2.0", "tslib": "2.8.1", "typescript": "5.9.3", - "vitest": "4.0.6" + "vitest": "4.0.7" }, "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "effector": "23", "effector-react": "23", "nanoid": "*", @@ -39,19 +39,26 @@ "url": "https://github.com/grlt-hub/react-slots.git" }, "homepage": "https://github.com/grlt-hub/react-slots", - "description": "Bring the power of slots to your React components effortlessly.", + "description": "Declarative slot system for React. Build extensible, plugin-ready components with dynamic injection powered by Effector", "keywords": [ "grlt", "grlt-hub", "react", + "react-slots", "slots", - "slot-based-architecture", - "dynamic-content", + "slot-pattern", + "component-slots", + "plugin-architecture", + "extensible-components", "component-injection", - "modular-components", - "react-library", - "ui-framework", - "content-management", - "frontend" + "dynamic-injection", + "event-driven", + "modular-ui", + "type-safe", + "composition", + "composable", + "lightweight", + "deferred-rendering", + "lazy-components" ] } diff --git a/sandbox.tsx b/sandbox.tsx new file mode 100644 index 0000000..c7c99a8 --- /dev/null +++ b/sandbox.tsx @@ -0,0 +1,21 @@ +import { createEvent } from 'effector'; +import { createGate } from 'effector-react'; +import React from 'react'; +import { createSlotIdentifier, createSlots } from './src'; + +const { slotsApi } = createSlots({ + ConfirmScreenBottom: createSlotIdentifier<{ id: number }>(), +} as const); + +const appGate = createGate(); + +slotsApi.insert.into.ConfirmScreenBottom({ + when: [appGate.open], + mapProps: (__, y) => ({ id: Number(y) }), + component: (props) =>

Hello world! {props.id}

, +}); + +slotsApi.insert.into.ConfirmScreenBottom({ + mapProps: (x) => x, + component: (props) =>

Hello world! {props.id}

, +}); diff --git a/src/__tests__/payload.spec-d.tsx b/src/__tests__/payload.spec-d.tsx index 8208f03..083f052 100644 --- a/src/__tests__/payload.spec-d.tsx +++ b/src/__tests__/payload.spec-d.tsx @@ -1,17 +1,20 @@ +import { createEvent } from 'effector'; import React from 'react'; import { expectTypeOf } from 'vitest'; -import { createSlotIdentifier, createSlots, type EmptyObject } from '../init'; +import { EmptyObject } from '../helpers'; +import { createSlotIdentifier, createSlots } from '../index'; -const slotId = createSlotIdentifier<{ text: string }>(); +const slotWithProps = createSlotIdentifier<{ text: string }>(); const noPropsSlot = createSlotIdentifier(); +const signal = createEvent(); const { slotsApi } = createSlots({ - Top: slotId, + Top: slotWithProps, Bottom: noPropsSlot, }); slotsApi.insert.into.Top({ - fn: (data) => ({ text: data.text }), + mapProps: (data) => ({ text: data.text }), component: (props) => { expectTypeOf<{ text: string }>(props); return
; @@ -19,16 +22,39 @@ slotsApi.insert.into.Top({ }); slotsApi.insert.into.Top({ + when: signal, + mapProps: (__, signalPayload) => ({ text: String(signalPayload) }), component: (props) => { - expectTypeOf(props); + expectTypeOf<{ text: string }>(props); return
; }, }); slotsApi.insert.into.Top({ - fn: () => {}, + when: [signal], + mapProps: (__, signalPayload) => ({ text: String(signalPayload) }), component: (props) => { - expectTypeOf(props); + expectTypeOf<{ text: string }>(props); + return
; + }, +}); + +slotsApi.insert.into.Top({ + when: [signal, createEvent()], + mapProps: (__, signalPayload) => { + const text = Array.isArray(signalPayload) ? signalPayload[0] : String(signalPayload); + + return { text }; + }, + component: (props) => { + expectTypeOf<{ text: string }>(props); + return
; + }, +}); + +slotsApi.insert.into.Top({ + component: (props) => { + expectTypeOf<{ text: string }>(props); return
; }, }); @@ -41,7 +67,23 @@ slotsApi.insert.into.Bottom({ }); slotsApi.insert.into.Top({ - fn: (data) => ({ text: data.text }), + mapProps: () => {}, + component: () =>
, +}); + +slotsApi.insert.into.Top({ + when: signal, + mapProps: (slotPayload, signalPayload) => ({ data: { signalPayload, slotPayload } }), + component: (props) => { + expectTypeOf<{ + data: { slotPayload: { text: string }; signalPayload: number }; + }>(props); + return
; + }, +}); + +slotsApi.insert.into.Top({ + mapProps: (data) => ({ text: data.text }), // @ts-expect-error component: (_: { wrong: number }) =>
, }); diff --git a/src/helpers.tsx b/src/helpers.tsx new file mode 100644 index 0000000..41af7c3 --- /dev/null +++ b/src/helpers.tsx @@ -0,0 +1,26 @@ +import React, { memo, useMemo } from 'react'; + +const isNil = (x: T | undefined | null): x is undefined | null => x === null || x === undefined; + +const insertSorted = (list: T[], element: T) => { + const elementOrder = element.order ?? 0; + const insertIndex = list.findIndex((existing) => (existing.order ?? 0) > elementOrder); + + return list.toSpliced(insertIndex === -1 ? list.length : insertIndex, 0, element); +}; + +type EmptyObject = Record; +type Entries = { + [K in keyof T]: [K, T[K]]; +}[keyof T][]; + +// @ts-expect-error its ok +const makeChildWithProps = (child) => + // @ts-expect-error its ok + memo((props) => { + const childProps = useMemo(() => child.mapProps(props), [props]); + + return ; + }); + +export { insertSorted, isNil, makeChildWithProps, type EmptyObject, type Entries }; diff --git a/src/index.tsx b/src/index.tsx index dda44de..7aec810 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1 +1,118 @@ -export { createSlotIdentifier, createSlots, type EmptyObject } from './init'; +import { createEvent, createStore } from 'effector'; +import { useStoreMap } from 'effector-react'; +import { nanoid } from 'nanoid'; +import React, { memo, type FunctionComponent } from 'react'; +import { insertSorted, isNil, makeChildWithProps, type Entries } from './helpers'; +import type { Payload } from './payload'; + +type CreateSlotIdentifier = () => (_: T) => T; + +const createSlotIdentifier: CreateSlotIdentifier = () => (props) => props; + +type SlotFunction = (_: T) => T; + +const createSlots = >>(config: T) => { + const entries = Object.entries(config) as Entries; + const keys = entries.map(([k]) => k); + + type SetApi = { + [key in keyof T]: T[key] extends (_: any) => unknown ? Payload[0]> : never; + }; + + type State = { + [key in keyof T]: (Parameters[0] & { id: string })[]; + }; + + type Slots = { + [key in keyof T]: FunctionComponent[0]>; + }; + + type ExtractData

= Parameters[0]; + + const $slots = createStore( + keys.reduce((acc, x) => { + acc[x] = []; + + return acc; + }, {} as State), + ); + + const insertApi = keys.reduce((acc, key) => { + const insert = createEvent>>[0]>(); + + const unwatch = insert.watch((payload) => { + if (isNil(payload.when)) { + unwatch(); + + return; + } + + const triggers = Array.isArray(payload.when) ? payload.when : [payload.when]; + + triggers.forEach((trigger) => { + // @ts-expect-error + trigger.watch((triggerPayload) => { + const { when, mapProps, ...data } = payload; + + // @ts-expect-error + const wrappedFn = mapProps ? (props: any) => mapProps(props, triggerPayload) : undefined; + + // @ts-expect-error + insert({ ...data, mapProps: wrappedFn }); + }); + }); + + unwatch(); + }); + + const immediateInsert = insert.filter({ fn: (x) => isNil(x.when) }); + + $slots.on(immediateInsert, (state, payload) => { + const item = { ...payload, id: nanoid(10) }; + const list = insertSorted(state[key], item); + + return { ...state, [key]: list }; + }); + + // @ts-expect-error its ok. avoid extra fn creation + acc[key] = insert; + + return acc; + }, {} as SetApi); + + const slots = keys.reduce((acc, key) => { + const component = memo>((props) => { + const slotChildren = useStoreMap($slots, (x) => x[key]); + + return slotChildren.map((child) => { + if (isNil(child.mapProps)) { + return ( + + + + ); + } + + const ChildWithProps = makeChildWithProps(child); + + return ( + + + + ); + }); + }); + + acc[key] = component; + + return acc; + }, {} as Slots); + + const slotsApi = { + insert: { into: insertApi }, + }; + + return { slotsApi, Slots: slots }; +}; + +export { createSlotIdentifier, createSlots }; diff --git a/src/init.tsx b/src/init.tsx deleted file mode 100644 index 1b614b2..0000000 --- a/src/init.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { createEvent, createStore } from 'effector'; -import { useStoreMap } from 'effector-react'; -import { nanoid } from 'nanoid'; -import React, { type FunctionComponent, memo, useMemo } from 'react'; - -type EmptyObject = Record; -type Entries = { - [K in keyof T]: [K, T[K]]; -}[keyof T][]; - -const isNil = (x: T | undefined | null): x is undefined | null => x === null || x === undefined; - -const insertAtPosition = (list: T[], position: number, element: T) => { - const newList = [...list]; - - if (position <= 0) { - newList.unshift(element); - } else if (position >= list.length) { - newList.push(element); - } else { - newList.splice(position, 0, element); - } - - return newList; -}; - -type CreateSlotIdentifier = () => (_: T) => T; - -const createSlotIdentifier: CreateSlotIdentifier = () => (props) => props; - -type SlotFunction = (_: T) => T; - -type Payload = (params: { - component: (props: unknown extends R ? EmptyObject : R extends void ? EmptyObject : R) => React.JSX.Element; - fn?: (arg: T) => R; - order?: number; -}) => void; - -const createSlots = >>(config: T) => { - const entries = Object.entries(config) as Entries; - const keys = entries.map(([k]) => k); - - type SetApi = { - [key in keyof T]: T[key] extends (_: any) => unknown ? Payload[0]> : never; - }; - - type State = { - [key in keyof T]: (Parameters[0] & { id: string })[]; - }; - - type Slots = { - [key in keyof T]: FunctionComponent[0]>; - }; - - type ExtractData

= Parameters[0]; - - const $slots = createStore( - keys.reduce((acc, x) => { - acc[x] = []; - - return acc; - }, {} as State), - ); - - const insertApi = keys.reduce((acc, key) => { - const evt = createEvent>>[0]>(); - - $slots.on(evt, (state, payload) => { - const item = { ...payload, id: nanoid(10) }; - const list = insertAtPosition(state[key], payload.order ?? state[key].length + 1, item); - const sortedList = list.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); - - return { ...state, [key]: sortedList }; - }); - - // @ts-expect-error its ok. avoid extra fn creation - acc[key] = evt; - - return acc; - }, {} as SetApi); - - // @ts-expect-error its ok - const makeChildWithProps = (child) => - // @ts-expect-error its ok - memo((props) => { - const childProps = useMemo(() => child.fn(props), [props]); - - return ( - - - - ); - }); - - const slots = keys.reduce((acc, key) => { - const component = memo>((props) => { - const childrens = useStoreMap($slots, (x) => x[key]); - - return childrens.map((child) => { - if (isNil(child.fn)) { - return ( - - - - ); - } - - const ChildWithProps = makeChildWithProps(child); - - return ( - - - - ); - }); - }); - - acc[key] = component; - - return acc; - }, {} as Slots); - - const slotsApi = { - insert: { into: insertApi }, - }; - - return { slotsApi, Slots: slots }; -}; - -export { createSlotIdentifier, createSlots, type EmptyObject, type Payload }; diff --git a/src/payload.ts b/src/payload.ts new file mode 100644 index 0000000..70c74cc --- /dev/null +++ b/src/payload.ts @@ -0,0 +1,32 @@ +import type { Event, EventPayload } from 'effector'; +import type { EmptyObject } from './helpers'; + +type ExtractWhenPayload = T extends Event ? P : T extends Event[] ? EventPayload : never; + +type Payload = { + // When mapProps is provided with when + | Event[]>(params: { + component: (props: unknown extends R ? EmptyObject : R extends void ? EmptyObject : R) => React.JSX.Element; + mapProps: (arg: T, whenPayload: ExtractWhenPayload) => R; + order?: number; + when: W; + }): void; + + // When mapProps is provided without when + (params: { + component: (props: unknown extends R ? EmptyObject : R extends void ? EmptyObject : R) => React.JSX.Element; + mapProps: (arg: T) => R; + order?: number; + when?: undefined; + }): void; + + // When mapProps is not provided + (params: { + component: (props: unknown extends T ? EmptyObject : T extends void ? EmptyObject : T) => React.JSX.Element; + mapProps?: undefined; + order?: number; + when?: undefined; + }): void; +}; + +export type { Payload }; diff --git a/tsconfig.json b/tsconfig.json index ba14f80..7a095b6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,5 +22,5 @@ "emitDeclarationOnly": true, "jsx": "react" }, - "exclude": ["**/__tests__/**", "*.config.ts", "node_modules", "dist"] + "exclude": ["**/__tests__/**", "*.config.ts", "node_modules", "dist", "sandbox.tsx"] }