| SlotComponent {
17 | return (
18 | typeof component === "function" && component.hasOwnProperty(COMPONENT_TYPE)
19 | );
20 | }
21 |
22 | export function isTemplateComponent(
23 | component: string | React.JSXElementConstructor,
24 | ): component is TemplateComponent {
25 | return (
26 | isReactSlotsComponent(component) &&
27 | component[COMPONENT_TYPE] === TEMPLATE_TYPE_IDENTIFIER
28 | );
29 | }
30 |
31 | export function isSlotComponent(
32 | component: string | React.JSXElementConstructor,
33 | ): component is SlotComponent {
34 | return (
35 | isReactSlotsComponent(component) &&
36 | component[COMPONENT_TYPE] === SLOT_TYPE_IDENTIFIER
37 | );
38 | }
39 |
40 | export function isTemplateElement(
41 | element: React.ReactNode,
42 | ): element is
43 | | TemplateComponentLikeElement
44 | | TemplateAsSlotComponentLikeElement {
45 | return React.isValidElement(element) && isTemplateComponent(element.type);
46 | }
47 |
48 | /** Note: {"slot-name": undefined} also passes */
49 | export function isNamedSlot(
50 | element: React.ReactElement,
51 | ): element is React.ReactElement<{ ["slot-name"]?: N }> {
52 | return element.props.hasOwnProperty("slot-name");
53 | }
54 |
--------------------------------------------------------------------------------
/examples/vite/src/hooks/useStateControl.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | /**
4 | * Makes it possible for components to be both controlled and uncontrolled:
5 | * - Derives and maintains one source of truth from value and defaultValue.
6 | * - calls onChange on value change
7 | * */
8 | export function useStateControl(
9 | value: T | undefined,
10 | defaultValue: T | undefined,
11 | onChange: ((v: T, ...args: unknown[]) => void) | undefined,
12 | ): [T | undefined, (nextValue: T) => void] {
13 | const isControlled = value !== undefined;
14 |
15 | const [internalState, setInternalState] = React.useState(() =>
16 | isControlled ? value : defaultValue,
17 | );
18 |
19 | if (
20 | !isControlled &&
21 | internalState === undefined &&
22 | defaultValue !== undefined
23 | ) {
24 | // defaultValue was undefined at first but changed to some value
25 | setInternalState(defaultValue);
26 | }
27 |
28 | if (isControlled && value !== internalState) {
29 | // is controlled and a new value was provided. Sync internal state
30 | setInternalState(value);
31 | }
32 |
33 | const isControlledRef = React.useRef(isControlled);
34 | if (isControlledRef.current !== isControlled) {
35 | const wasControlled = isControlledRef.current;
36 | console.error(
37 | `A component changed from ${
38 | wasControlled ? "controlled" : "uncontrolled"
39 | } to ${
40 | isControlled ? "controlled" : "uncontrolled"
41 | }. This may lead to unexpected behavior.`,
42 | );
43 | isControlledRef.current = isControlled;
44 | }
45 |
46 | const setState = React.useCallback(
47 | (value: T) => {
48 | if (onChange && value !== internalState) {
49 | onChange(value);
50 | }
51 |
52 | if (!isControlled) {
53 | setInternalState(value);
54 | }
55 | },
56 | [internalState, isControlled, onChange],
57 | );
58 |
59 | return [internalState, setState];
60 | }
61 |
--------------------------------------------------------------------------------
/docs/pages/recommendations.mdx:
--------------------------------------------------------------------------------
1 | # Recommendations
2 |
3 | ## Better IDE Experience With Typescript
4 |
5 | If you're working with TypeScript, you can take steps to streamline your
6 | development process by moving the `Slot` unions into the template argument of
7 | your component like this:
8 |
9 | ```tsx
10 | type Props = {
11 | children: SlotChildren,
12 | someOtherProp1: string,
13 | someOtherProp2: number
14 | }
15 |
16 | function MyComponent | Slot<"foo">>(props: Props) {
17 | return ...
18 | }
19 | ```
20 |
21 | While this syntax may seem a bit more substantial, it offers the advantage of
22 | allowing you to easily identify the available slots for a component by hovering
23 | over its name. This means you won't need to navigate through the file to see
24 | which slots are supported.
25 |
26 | ## Untyped Templates and `noUncheckedIndexedAccess`
27 |
28 | In TypeScript, it's not possible to type an object that includes every possible
29 | property, which `template` and `slot` do. This can be frustrating when using
30 | untyped `template` or `slot` in your code, as it may lead to an error like this:
31 |
32 | ```
33 | 'template.default' cannot be used as a JSX component.
34 | Its type 'TemplateComponent | undefined' is not a valid JSX element type.
35 | Type 'undefined' is not assignable to type 'ElementType'.
36 | ```
37 |
38 | This error will only appear if you have `noUncheckedIndexedAccess` set to `true`
39 | in your `tsconfig` (by default, this rule is off). If you prefer to keep this
40 | rule enabled, you have two alternative options:
41 |
42 | 1. Always type your `children` with `SlotChildren` and `Slot` and utilize typed
43 | templates created with `CreateTemplate`.
44 | 2. Check if the property exists before accessing it, like this:
45 | `template.default && `. However, please note that this
46 | check is obsolete because every property on the `template` and `slot` objects
47 | is always defined.
48 |
--------------------------------------------------------------------------------
/packages/unplugin-transform-react-slots/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@beqa/unplugin-transform-react-slots",
3 | "version": "1.0.1",
4 | "description": "JSX to slot function transpilation plugin for some of the common build systems",
5 | "author": "Beqa",
6 | "license": "MIT",
7 | "keywords": [
8 | "unplugin",
9 | "rollup",
10 | "vite",
11 | "esbuild",
12 | "react-slots"
13 | ],
14 | "publishConfig": {
15 | "access": "public"
16 | },
17 | "bugs": {
18 | "url": "https://github.com/Flammae/react-slots/issues"
19 | },
20 | "repository": {
21 | "type": "git",
22 | "url": "git+https://github.com/Flammae/react-slots.git"
23 | },
24 | "main": "./dist/index.js",
25 | "module": "./dist/index.mjs",
26 | "types": "./dist/index.d.ts",
27 | "exports": {
28 | ".": {
29 | "require": "./dist/index.js",
30 | "import": "./dist/index.mjs"
31 | },
32 | "./vite": {
33 | "require": "./dist/vite.js",
34 | "import": "./dist/vite.mjs"
35 | },
36 | "./rollup": {
37 | "require": "./dist/rollup.js",
38 | "import": "./dist/rollup.mjs"
39 | },
40 | "./esbuild": {
41 | "require": "./dist/esbuild.js",
42 | "import": "./dist/esbuild.mjs"
43 | },
44 | "./webpack": {
45 | "require": "./dist/webpack.js",
46 | "import": "./dist/webpack.mjs"
47 | }
48 | },
49 | "scripts": {
50 | "build": "tsup",
51 | "test": "pnpm run build && pnpm run test:build && pnpm run test:check",
52 | "test:watch": "pnpm run build && pnpm run test:build && vitest",
53 | "test:build": "tsx test/bundleTests",
54 | "test:check": "vitest run",
55 | "typecheck": "tsc"
56 | },
57 | "dependencies": {
58 | "@babel/core": "^7.23.0",
59 | "@babel/plugin-syntax-typescript": "^7.22.5",
60 | "@beqa/babel-plugin-transform-react-slots": "workspace:*",
61 | "@rollup/pluginutils": "^5.0.4",
62 | "unplugin": "^1.5.0"
63 | },
64 | "devDependencies": {
65 | "@beqa/react-slots": "workspace:*"
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/examples/vite/src/components/dialog/Dialog.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | CreateTemplate,
3 | OverrideNode,
4 | Slot,
5 | SlotChildren,
6 | template,
7 | useSlot,
8 | } from "@beqa/react-slots";
9 | import { useDialogTriggerContext } from "./DialogTriggerContent";
10 | import Button from "../button/Button";
11 |
12 | type Props = {
13 | children: SlotChildren<
14 | Slot<"title"> | Slot<"content"> | Slot<"primary"> | Slot<"secondary">
15 | >;
16 | disableAutoClose?: boolean;
17 | };
18 |
19 | export default function Dialog(props: Props) {
20 | const { close, titleId } = useDialogTriggerContext();
21 | const { slot } = useSlot(props.children);
22 |
23 | // Auto close dialog if uncontrolled after any button click
24 | const onClickOverride = OverrideNode.chainAfter(() => {
25 | if (props.disableAutoClose) {
26 | return;
27 | }
28 | close();
29 | });
30 |
31 | return (
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | prop || "secondary",
49 | }}
50 | />
51 |
52 |
53 | prop || "primary",
59 | }}
60 | />
61 |
62 |
63 |
64 | );
65 | }
66 |
67 | export const dialogTemplate = template as CreateTemplate;
68 |
--------------------------------------------------------------------------------
/packages/unplugin-transform-react-slots/test/vite/vite.test.ts:
--------------------------------------------------------------------------------
1 | import { test, expect, describe } from "vitest";
2 | import fs from "fs/promises";
3 | import path from "path";
4 |
5 | async function readFile(relative: string) {
6 | return await fs.readFile(path.resolve(__dirname, relative), "utf-8");
7 | }
8 |
9 | function includesLast(file: string, test: string) {
10 | return file.lastIndexOf(test) > 0;
11 | }
12 |
13 | describe("Esbuild config", () => {
14 | const disabledNoImport = readFile("./dist/disabled-no-import.mjs");
15 | const disabledNoImportTS = readFile("./dist/disabled-no-import-ts.mjs");
16 |
17 | const disabledWithPragma = readFile("./dist/disabled-with-pragma.mjs");
18 | const disabledWithPragmaTS = readFile("./dist/disabled-with-pragma-ts.mjs");
19 |
20 | const working = readFile("./dist/working.mjs");
21 | const workingTS = readFile("./dist/working-ts.mjs");
22 |
23 | test("won't transform when not imported", async () => {
24 | const [js, ts] = await Promise.all([disabledNoImport, disabledNoImportTS]);
25 | expect(includesLast(js, "createElement(slot.default, null)")).toBe(true);
26 | expect(includesLast(ts, "createElement(slot.default, null)")).toBe(true);
27 | });
28 | test("won't transform when disabled with pragma", async () => {
29 | const [js, ts] = await Promise.all([
30 | disabledWithPragma,
31 | disabledWithPragmaTS,
32 | ]);
33 | expect(includesLast(js, "createElement(slot.default, null)")).toBe(true);
34 | expect(includesLast(ts, "createElement(slot.default, null)")).toBe(true);
35 | });
36 | test("transforms slot elements to functions", async () => {
37 | const [js, ts] = await Promise.all([working, workingTS]);
38 | expect(
39 | includesLast(
40 | js,
41 | 'slot.default(/* @__PURE__ */ reactExports.createElement("default-content-wrapper", null));',
42 | ),
43 | ).toBe(true);
44 | expect(
45 | includesLast(
46 | ts,
47 | 'slot.default(/* @__PURE__ */ React.createElement("default-content-wrapper", null));',
48 | ),
49 | ).toBe(true);
50 | });
51 | });
52 |
--------------------------------------------------------------------------------
/packages/unplugin-transform-react-slots/test/esbuild/esbuild.test.ts:
--------------------------------------------------------------------------------
1 | import { test, expect, describe } from "vitest";
2 | import fs from "fs/promises";
3 | import path from "path";
4 |
5 | async function readFile(relative: string) {
6 | return await fs.readFile(path.resolve(__dirname, relative), "utf-8");
7 | }
8 |
9 | function includesLast(file: string, test: string) {
10 | return file.lastIndexOf(test) > 0;
11 | }
12 |
13 | describe("Esbuild config", () => {
14 | const disabledNoImport = readFile("./dist/disabled-no-import.js");
15 | const disabledNoImportTS = readFile("./dist/disabled-no-import-ts.js");
16 |
17 | const disabledWithPragma = readFile("./dist/disabled-with-pragma.js");
18 | const disabledWithPragmaTS = readFile("./dist/disabled-with-pragma-ts.js");
19 |
20 | const working = readFile("./dist/working.js");
21 | const workingTS = readFile("./dist/working-ts.js");
22 |
23 | test("won't transform when not imported", async () => {
24 | const [js, ts] = await Promise.all([disabledNoImport, disabledNoImportTS]);
25 | expect(includesLast(js, "React.createElement(slot.default, null)")).toBe(
26 | true,
27 | );
28 | expect(includesLast(ts, "React.createElement(slot.default, null)")).toBe(
29 | true,
30 | );
31 | });
32 | test("won't transform when disabled with pragma", async () => {
33 | const [js, ts] = await Promise.all([
34 | disabledWithPragma,
35 | disabledWithPragmaTS,
36 | ]);
37 | expect(includesLast(js, "React.createElement(slot.default, null)")).toBe(
38 | true,
39 | );
40 | expect(includesLast(ts, "React.createElement(slot.default, null)")).toBe(
41 | true,
42 | );
43 | });
44 | test("transforms slot elements to functions", async () => {
45 | const [js, ts] = await Promise.all([working, workingTS]);
46 | expect(
47 | includesLast(
48 | js,
49 | `slot.default(/* @__PURE__ */ React.createElement("default-content-wrapper", null));`,
50 | ),
51 | ).toBe(true);
52 | expect(
53 | includesLast(
54 | ts,
55 | `slot.default(/* @__PURE__ */ React.createElement("default-content-wrapper", null));`,
56 | ),
57 | ).toBe(true);
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/packages/babel-plugin-transform-react-slots/test/fixtures/all-allowed-syntax-js/code.jsx:
--------------------------------------------------------------------------------
1 | import _defaultExport, { useSlot, _anythingElse } from "@beqa/react-slots";
2 | import * as ReactSlots from "@beqa/react-slots";
3 | import { useSlot as useSlotAlias } from "@beqa/react-slots";
4 |
5 | let a = useSlotAlias;
6 | const b = ReactSlots;
7 | let c = b.useSlot;
8 | const d = ReactSlots.useSlot;
9 | let {
10 | slot: { ...e },
11 | } = b.useSlot(); // but won't transform because it's lower case
12 |
13 | // Transformation can be done in any scope
14 | if (true) {
15 | a(); // Ignore, not using returned value
16 | let e = b.useSlot(); //
17 | const { f } = c(); // Ignore, f is not a slot
18 | let { slot, g } = d(); //
19 | const { slot: h } = useSlot(); //
20 | let {
21 | slot: { i: SlotName },
22 | } = useSlot(); //
23 | const { ...j } = e; // ;
24 | let { k: Anything, ...l } = ReactSlots.useSlot().slot; //
25 |
26 | e.slot.anything(); // Ignore, not a jsx element
27 | ; // MUST TRANSFORM
28 | children ; // MUST TRANSFORM
29 | ; // MUST TRANSFORM
30 |
31 | {/* MUST TRANSFORM */}
32 |
33 |
;
34 |
38 |
39 |
40 | }
41 | />;
42 | }
43 |
44 |
; // won't transform because it's a lowercase name and jsx won't treat it as a variable name.
45 |
; // won't transform because h is defined in a different scope
46 |
47 | d; // Nothing to see here
48 |
49 | let f = c();
50 |
; // won't transform because not accessing slot property on c();
51 |
52 | function _functionName() {
53 | let f = c;
54 | let {
55 | slot: { ...g },
56 | } = f(); //
57 |
58 | return
; // MUST TRANSFORM
59 | }
60 |
61 | // The following syntax does nothing but should not throw;
62 | if (useSlotAlias) {
63 | }
64 | if (useSlotAlias().slot.name) {
65 | }
66 | ((useSlotAlias && useSlotAlias().slot) || useSlotAlias().slot.default) ?? (
67 |
// Must transform
68 | );
69 |
--------------------------------------------------------------------------------
/examples/vite/src/components/dialog/DialogTrigger.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Slot,
3 | SlotChildren,
4 | useSlot,
5 | OverrideNode,
6 | template,
7 | CreateTemplate,
8 | } from "@beqa/react-slots";
9 | import Modal from "react-modal";
10 | import { useStateControl } from "../../hooks/useStateControl";
11 | import { DialogTriggerContextProvider } from "./DialogTriggerContent";
12 | import Button from "../button/Button";
13 | import { useCallback } from "react";
14 |
15 | const DIALOG_TITLE_ID = "dialog-title";
16 |
17 | type Props = {
18 | children: SlotChildren<
19 | Slot | Slot<"dialog", { close: () => void; titleId: string }>
20 | >;
21 | onToggle?: (nextIsOpen: boolean) => void;
22 | // Supports both controlled and uncontrolled variants (similar to value and defaultValue props on
)
23 | isOpen?: boolean;
24 | defaultIsOpen?: boolean;
25 | };
26 |
27 | export function DialogTrigger(props: Props) {
28 | const { slot } = useSlot(props.children);
29 |
30 | const [isOpen, setIsOpen] = useStateControl(
31 | props.isOpen,
32 | props.defaultIsOpen,
33 | props.onToggle,
34 | );
35 |
36 | const close = useCallback(() => setIsOpen(false), [setIsOpen]);
37 |
38 | return (
39 | <>
40 |
41 |
42 | {
47 | setIsOpen(!isOpen);
48 | }),
49 | }}
50 | >
51 | Trigger It!
52 |
53 |
54 | {/* Wrap whatever element consumer provides for the dialog slot in a context so it can use setIsOpen inside */}
55 |
56 |
57 | {/* Passing these props up isn't necessary if consumer only uses Dialog for this slot, but if they decide to provide something custom instead, it could come in handy */}
58 | setIsOpen(false)}
61 | />
62 |
63 |
64 | >
65 | );
66 | }
67 |
68 | export const dialogTriggerTemplate = template as CreateTemplate<
69 | Props["children"]
70 | >;
71 |
--------------------------------------------------------------------------------
/docs/pages/advanced/multiple-override-node.mdx:
--------------------------------------------------------------------------------
1 | # Using Multiple `OverrideNode` Elements
2 |
3 | You can include multiple `OverrideNode` elements for a single slot as long as
4 | they are all top-level children. When you use multiple `OverrideNode` elements,
5 | their overrides are applied from top to bottom to the parent-provided content.
6 | The result of the previous override becomes the input for the next
7 | `OverrideNode`. When the parent doesn't provide content, only the `OverrideNode`
8 | elements that wrap the fallback content will be applied.
9 |
10 | In this example, a component restricts the node type for the "trigger" slot to
11 | be either a button element or a string and then handles their overrides
12 | separately:
13 |
14 | ```jsx
15 |
16 |
17 | {/* Override string nodes */}
18 | {
22 | {node} ;
23 | }}
24 | />
25 | {/* Override button elements */}
26 |
33 | {/* Nothing was provided, render fallback */}
34 | Trigger
35 |
36 | ```
37 |
38 | Here's an example that demonstrates the difference between applying overrides to
39 | provided content versus fallback content:
40 |
41 | ```jsx
42 | function MyComponent(children) {
43 | const { slot } = useSlot(children);
44 |
45 | return (
46 |
47 | {node}
} />
48 | "added-class" }}>
49 | Fallback
50 |
51 | "added-id" }}>
52 | Second Fallback
53 |
54 |
55 | );
56 | }
57 |
58 | // Providing content:
59 |
Content ;
60 | // Expected HTML output:
61 |
62 | Content
63 |
;
64 |
65 | // No content:
66 |
;
67 | // Expected HTML output:
68 |
Fallback ;
69 |
Second Fallback
;
70 | ```
71 |
--------------------------------------------------------------------------------
/packages/babel-plugin-transform-react-slots/test/fixtures/all-allowed-syntax-ts/code.tsx:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 |
3 | import _defaultExport, { useSlot, _anythingElse } from "@beqa/react-slots";
4 | import * as ReactSlots from "@beqa/react-slots";
5 | import { useSlot as useSlotAlias } from "@beqa/react-slots";
6 |
7 | let a = useSlotAlias;
8 | const b = ReactSlots;
9 | let c = (b as SomeType).useSlot;
10 | const d = ReactSlots.useSlot;
11 | let {
12 | slot: { ...e },
13 | }: any = b.useSlot() as SomeType; //
but won't transform because it's lower case
14 |
15 | // Transformation can be done in any scope
16 | if (true) {
17 | a(); // Ignore, not using returned value
18 | let e: any = (b satisfies any).useSlot() as any; //
19 | const { f }: SomeAnnotation = c
(); // Ignore, f is not a slot
20 | let { slot, g } = d() as unknown as SomeType; //
21 | const { slot: h } = useSlot(); //
22 | let {
23 | slot: { i: SlotName },
24 | } = useSlot(); //
25 | const { ...j } = e; // ;
26 | let { k: Anything, ...l } = ReactSlots.useSlot().slot; //
27 |
28 | l as any; // Ignore, expression statement
29 |
30 | e.slot.anything(); // Ignore, not a jsx element
31 | />; // MUST TRANSFORM
32 | >children ; // MUST TRANSFORM
33 | ; // MUST TRANSFORM
34 |
35 | {/* MUST TRANSFORM */}
36 |
37 |
;
38 |
42 |
prop1={1} prop2="string" prop3 />
43 |
44 | }
45 | />;
46 | }
47 |
48 | ; // won't transform because it's a lowercase name and jsx won't treat it as a variable name.
49 | ; // won't transform because h is defined in a different scope
50 |
51 | d; // Nothing to see here
52 |
53 | let f = c();
54 | ; // won't transform because not accessing slot property on c();
55 |
56 | function _functionName() {
57 | let f: SomeTypeWithArgs =
58 | c satisfies any satisfies unknown as SomeTypeWithArgs;
59 | let {
60 | slot: { ...g },
61 | } = f(); //
62 |
63 | return ; // MUST TRANSFORM
64 | }
65 |
66 | // The following syntax does nothing but should not throw;
67 | if (useSlotAlias) {
68 | }
69 | if (useSlotAlias().slot.name as unknown) {
70 | }
71 | (((useSlotAlias as any) && (useSlotAlias().slot as Something)) ||
72 | useSlotAlias().slot.default) ??
73 | f.slot.default(null); // Must transform
74 |
--------------------------------------------------------------------------------
/packages/babel-plugin-transform-react-slots/test/fixtures/all-allowed-syntax-js/output.mjs:
--------------------------------------------------------------------------------
1 | import _defaultExport, { useSlot, _anythingElse } from "@beqa/react-slots";
2 | import * as ReactSlots from "@beqa/react-slots";
3 | import { useSlot as useSlotAlias } from "@beqa/react-slots";
4 | let a = useSlotAlias;
5 | const b = ReactSlots;
6 | let c = b.useSlot;
7 | const d = ReactSlots.useSlot;
8 | let {
9 | slot: { ...e },
10 | } = b.useSlot(); // but won't transform because it's lower case
11 |
12 | // Transformation can be done in any scope
13 | if (true) {
14 | a(); // Ignore, not using returned value
15 | let e = b.useSlot(); //
16 | const { f } = c(); // Ignore, f is not a slot
17 | let { slot, g } = d(); //
18 | const { slot: h } = useSlot(); //
19 | let {
20 | slot: { i: SlotName },
21 | } = useSlot(); //
22 | const { ...j } = e; // ;
23 | let { k: Anything, ...l } = ReactSlots.useSlot().slot; //
24 |
25 | e.slot.anything(); // Ignore, not a jsx element
26 | slot.anything( ); // MUST TRANSFORM
27 | h.anything(children ); // MUST TRANSFORM
28 | SlotName( , {
29 | prop1: 1,
30 | prop2: "string",
31 | prop3: true,
32 | }); // MUST TRANSFORM
33 |
34 | {/* MUST TRANSFORM */}
35 | {j.slot.anything( )}
36 |
;
37 |
42 | {l.anythingElse(
, {
43 | prop1: 1,
44 | prop2: "string",
45 | prop3: true,
46 | })}
47 | ,
48 | {
49 | prop1: 1,
50 | prop2: "string",
51 | prop3: true,
52 | }
53 | )
54 | }
55 | />;
56 | }
57 |
; // won't transform because it's a lowercase name and jsx won't treat it as a variable name.
58 |
; // won't transform because h is defined in a different scope
59 |
60 | d; // Nothing to see here
61 |
62 | let f = c();
63 |
; // won't transform because not accessing slot property on c();
64 |
65 | function _functionName() {
66 | let f = c;
67 | let {
68 | slot: { ...g },
69 | } = f(); //
70 |
71 | return g.anything(
); // MUST TRANSFORM
72 | }
73 |
74 | // The following syntax does nothing but should not throw;
75 | if (useSlotAlias) {
76 | }
77 | if (useSlotAlias().slot.name) {
78 | }
79 | ((useSlotAlias && useSlotAlias().slot) || useSlotAlias().slot.default) ??
80 | f.slot.default(
); // Must transform
--------------------------------------------------------------------------------
/packages/babel-plugin-transform-react-slots/test/fixtures/all-allowed-syntax-ts/output.mjs:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 |
3 | import { useSlot } from "@beqa/react-slots";
4 | import * as ReactSlots from "@beqa/react-slots";
5 | import { useSlot as useSlotAlias } from "@beqa/react-slots";
6 | let a = useSlotAlias;
7 | const b = ReactSlots;
8 | let c = b.useSlot;
9 | const d = ReactSlots.useSlot;
10 | let {
11 | slot: { ...e },
12 | } = b.useSlot(); //
but won't transform because it's lower case
13 |
14 | // Transformation can be done in any scope
15 | if (true) {
16 | a(); // Ignore, not using returned value
17 | let e = b.useSlot(); //
18 | const { f } = c(); // Ignore, f is not a slot
19 | let { slot, g } = d(); //
20 | const { slot: h } = useSlot(); //
21 | let {
22 | slot: { i: SlotName },
23 | } = useSlot(); //
24 | const { ...j } = e; //
;
25 | let { k: Anything, ...l } = ReactSlots.useSlot().slot; //
26 |
27 | l; // Ignore, expression statement
28 |
29 | e.slot.anything(); // Ignore, not a jsx element
30 | slot.anything(
); // MUST TRANSFORM
31 | h.anything(
children ); // MUST TRANSFORM
32 | SlotName(
, {
33 | prop1: 1,
34 | prop2: "string",
35 | prop3: true,
36 | }); // MUST TRANSFORM
37 |
38 | {/* MUST TRANSFORM */}
39 | {j.slot.anything( )}
40 |
;
41 |
46 | {l.anythingElse(
, {
47 | prop1: 1,
48 | prop2: "string",
49 | prop3: true,
50 | })}
51 | ,
52 | {
53 | prop1: 1,
54 | prop2: "string",
55 | prop3: true,
56 | }
57 | )
58 | }
59 | />;
60 | }
61 |
; // won't transform because it's a lowercase name and jsx won't treat it as a variable name.
62 |
; // won't transform because h is defined in a different scope
63 |
64 | d; // Nothing to see here
65 |
66 | let f = c();
67 |
; // won't transform because not accessing slot property on c();
68 |
69 | function _functionName() {
70 | let f = c;
71 | let {
72 | slot: { ...g },
73 | } = f(); //
74 |
75 | return g.anything(
); // MUST TRANSFORM
76 | }
77 |
78 | // The following syntax does nothing but should not throw;
79 | if (useSlotAlias) {
80 | }
81 | if (useSlotAlias().slot.name) {
82 | }
83 | ((useSlotAlias && useSlotAlias().slot) || useSlotAlias().slot.default) ??
84 | f.slot.default(null); // Must transform
--------------------------------------------------------------------------------
/docs/pages/caveats.mdx:
--------------------------------------------------------------------------------
1 | # Caveats
2 |
3 | ## Slot Content Keys
4 |
5 | When `useSlot` parses `children`, it flattens the arrays. This allows it to
6 | correctly identify content for slots. For this reason, keys must be unique
7 | within the slot content, even if they are part of different arrays.
8 |
9 | ```jsx
10 | function MyComponent({ children }) {
11 | const { slot } = useSlot(children);
12 | return
;
13 | }
14 |
15 | // ❌ Incorrect: Duplicate keys 1 and 2
16 |
17 | {[
18 | First node
,
19 | Second second
20 | ]}
21 | {[
22 | Third node
23 | ]}
24 | Fourth node
25 | Fifth node
26 | Sixth node
27 |
28 |
29 | // ✅ Correct: Keys are different.
30 |
31 | {[
32 | First node
,
33 | Second second
34 | ]}
35 | {[
36 | Third node
37 | ]}
38 | Fourth node
39 | Fifth node
40 | Sixth node
41 |
42 | ```
43 |
44 | ## Template as Custom Component Footgun
45 |
46 | When you specify a custom component with the template element's `as` prop, you
47 | must exercise caution. This is because the `children` you specify for the
48 | template element won't be the same `children` that will be passed to the `as`
49 | element. `react-slots` is likely to modify the `children` in a way that's
50 | significantly different from what you might expect. This is especially true when
51 | `OverrideNode` is used within the slot. For this reason, the `as` element's
52 | `children` must be of type `ReactNode` and should only be used within the `as`
53 | element for the purpose of rendering.
54 |
55 | ```tsx
56 | function MyComponent({ children }: { children: string }) {
57 | performSideEffect(children);
58 | return
{children}
;
59 | }
60 | // ❌ Not Allowed: MyComponent expects a string to perform a side effect with.
61 | // The provided node will likely not be a string.
62 |
Content ;
63 |
64 | function MySecondComponent({ children }: { children: React.ReactNode }) {
65 | return shouldRenderChildren ? children : "default children";
66 | }
67 | // ⚠️ Caution: MySecondComponent may or may not render children.
68 |
Content ;
69 |
70 | function MyThirdComponent({ children }: { children: React.ReactNode }) {
71 | return
{children}
;
72 | }
73 | // ✅ Allowed: MyThirdComponent always returns unmodified children.
74 |
Content ;
75 | ```
76 |
77 | If you are using typescript, compiler won't allow you to specify a component
78 | whose `children` is not assignable to `ReactNode`
79 |
--------------------------------------------------------------------------------
/packages/unplugin-transform-react-slots/src/index.ts:
--------------------------------------------------------------------------------
1 | import { UnpluginInstance, createUnplugin } from "unplugin";
2 | import { createFilter } from "@rollup/pluginutils";
3 | import { type Options, resolveOption, defaultInclude } from "./core/options";
4 | import { transformAsync } from "@babel/core";
5 | import transformReactSlots from "@beqa/babel-plugin-transform-react-slots";
6 | import SyntaxTypescript from "@babel/plugin-syntax-typescript";
7 |
8 | // Matches @disable-transform-react-slots at the very start. Only line comments or block comments can precede it
9 | const isDisabledRegex =
10 | /^(?:\s*(?:\/\/[^\n\r]*|\/\*(?:.|[\n\r])*?\*\/))*\s*\/\/\s*@disable-transform-react-slots\W/;
11 |
12 | function appendTestComment(code: string, isDisabled: boolean): string {
13 | if (isDisabled) {
14 | code +=
15 | "/* slot transformation skipped by unplugin-transform-react-slots */";
16 | } else {
17 | code += "/* slot transformation done by unplugin-transform-react-slots */";
18 | }
19 | return code;
20 | }
21 |
22 | export default createUnplugin
((rawOptions) => {
23 | const options = resolveOption(rawOptions);
24 | const filter = createFilter(options.include, options.exclude);
25 |
26 | return {
27 | name: "unplugin-transform-react-slots",
28 |
29 | enforce: "pre",
30 |
31 | transformInclude(id) {
32 | return filter(id);
33 | },
34 |
35 | async transform(code, id) {
36 | if (id.endsWith(".ts")) {
37 | return appendTestComment(code, true);
38 | }
39 |
40 | if (isDisabledRegex.test(code)) {
41 | return appendTestComment(code, true);
42 | }
43 |
44 | if (!(code.includes("@beqa/react-slots") && code.includes("useSlot"))) {
45 | return appendTestComment(code, true);
46 | }
47 |
48 | const transformed = await transformAsync(code, {
49 | plugins: [
50 | id.endsWith(".tsx") ? [SyntaxTypescript, { isTSX: true }] : "",
51 | transformReactSlots,
52 | ].filter(Boolean),
53 | filename: id,
54 | });
55 |
56 | if (!transformed) {
57 | throw new Error(
58 | "unplugin-transform-react-slots failed to transform file: " + id,
59 | );
60 | }
61 |
62 | // TODO: test if returning babel sourcemap makes it better or worse
63 | return transformed.code && appendTestComment(transformed.code, false);
64 | },
65 |
66 | esbuild: {
67 | onLoadFilter:
68 | !!options.include && !Array.isArray(options.include)
69 | ? options.include
70 | : defaultInclude,
71 | loader(code, id) {
72 | if (id.endsWith("tsx")) {
73 | return "tsx";
74 | }
75 |
76 | if (id.endsWith("ts")) {
77 | return "ts";
78 | }
79 |
80 | return "jsx";
81 | },
82 | },
83 | };
84 | }) as Pick<
85 | UnpluginInstance,
86 | "esbuild" | "rollup" | "vite" | "webpack"
87 | >;
88 |
--------------------------------------------------------------------------------
/docs/pages/tutorial/enforcing-node-type.mdx:
--------------------------------------------------------------------------------
1 | # Enforcing the Node Type
2 |
3 | One of the simplest and most common use cases for `OverrideNode` is enforcing
4 | that parents provide a specific type of node for a slot. This practice is common
5 | in regular HTML elements. For example, a `` element can only have `` as
6 | a child, a `` can only contain ``, and a `` should be a
7 | direct child of ``.
8 |
9 | To specify the allowed nodes for a slot, include `OverrideNode` with the
10 | `allowedNodes` prop as a top-level child of the slot element. The `allowedNodes`
11 | must be an array of:
12 |
13 | - Strings for built-in elements, like `"div"`.
14 | - References to custom components (e.g., `Button`, `MyCustomComponent`).
15 | - The `String` constructor (note the capital letter) for string nodes.
16 | - The `Number` constructor (note the capital letter) for number nodes.
17 |
18 | By default, when a parent provides a node that is not allowed, an error will be
19 | thrown. You can customize this behavior using the `enforce` prop on
20 | `OverrideNode`:
21 |
22 | - `enforce="throw"` throws an error.
23 | - `enforce="remove"` removes disallowed nodes.
24 | - `enforce="ignore"` keeps the disallowed nodes but doesn't execute custom logic
25 | specified by `props` and `node` props for that node (More on that later).
26 |
27 | Here's an example with a `Heading` element that only allows string and number
28 | nodes, as well as the `span` element for its default slot:
29 |
30 | ```jsx {7}
31 | function Heading({ children, level }) {
32 | const { slot } = useSlot(children);
33 | const HeadingElement = "h" + level;
34 | return (
35 |
36 |
37 |
38 |
39 |
40 | );
41 | }
42 |
43 | // ✅ Correct usage:
44 | This is a heading level {2} ;
45 |
46 | // ✅ Correct usage:
47 |
48 | This is a heading level {2}
49 | ;
50 |
51 | // ✅ Correct usage:
52 |
53 | {() => "This is a "}
54 |
55 | heading
56 |
57 | ;
58 |
59 | // ❌ Incorrect; will throw an error:
60 |
61 | This is not allowed
62 | ;
63 | ```
64 |
65 | And here's an example with a `List` element that only allows `ListItem` custom
66 | components for its default slot and removes any other nodes:
67 |
68 | ```jsx {7}
69 | function ListItem() { // Implementation Omitted for brevity }
70 |
71 | function List({ children }) {
72 | const { slot } = useSlot(children);
73 | return (
74 |
75 |
76 |
77 | )
78 | }
79 |
80 | // Usage:
81 |
82 | {/* Kept */}
83 | Foo
84 | {/* Kept */}
85 | Bar
86 | {/* Removed */}
87 | Baz
88 | {/* Removed */}
89 | Qux
90 |
91 | ```
92 |
--------------------------------------------------------------------------------
/packages/unplugin-transform-react-slots/test/rollup/rollup.test.ts:
--------------------------------------------------------------------------------
1 | import { test, expect, describe } from "vitest";
2 | import fs from "fs/promises";
3 | import path from "path";
4 |
5 | async function readFile(relative: string) {
6 | return await fs.readFile(path.resolve(__dirname, relative), "utf-8");
7 | }
8 |
9 | function includesLast(file: string, test: string) {
10 | return file.lastIndexOf(test) > 0;
11 | }
12 |
13 | describe("Rollup config", () => {
14 | const disabledNoImport = readFile("./dist/disabled-no-import.js");
15 | const disabledNoImportTS = readFile("./dist/disabled-no-import-ts.js");
16 |
17 | const disabledWithPragma = readFile("./dist/disabled-with-pragma.js");
18 | const disabledWithPragmaTS = readFile("./dist/disabled-with-pragma-ts.js");
19 |
20 | const working = readFile("./dist/working.js");
21 | const workingTS = readFile("./dist/working-ts.js");
22 |
23 | test("won't transform when not imported", async () => {
24 | const [js, ts] = await Promise.all([disabledNoImport, disabledNoImportTS]);
25 | expect(includesLast(js, "createElement(slot.default, null)")).toBe(true);
26 | expect(
27 | includesLast(
28 | js,
29 | "/* slot transformation skipped by unplugin-transform-react-slots */",
30 | ),
31 | ).toBe(true);
32 |
33 | expect(includesLast(ts, "createElement(slot.default, null)")).toBe(true);
34 | expect(
35 | includesLast(
36 | ts,
37 | "/* slot transformation skipped by unplugin-transform-react-slots */",
38 | ),
39 | ).toBe(true);
40 | });
41 |
42 | test("won't transform when disabled with pragma", async () => {
43 | const [js, ts] = await Promise.all([
44 | disabledWithPragma,
45 | disabledWithPragmaTS,
46 | ]);
47 | expect(includesLast(js, "createElement(slot.default, null)")).toBe(true);
48 | expect(
49 | includesLast(
50 | js,
51 | "/* slot transformation skipped by unplugin-transform-react-slots */",
52 | ),
53 | ).toBe(true);
54 |
55 | expect(includesLast(ts, "createElement(slot.default, null)")).toBe(true);
56 | expect(
57 | includesLast(
58 | ts,
59 | "/* slot transformation skipped by unplugin-transform-react-slots */",
60 | ),
61 | ).toBe(true);
62 | });
63 |
64 | test("transforms slot elements to functions", async () => {
65 | const [js, ts] = await Promise.all([working, workingTS]);
66 | expect(
67 | includesLast(
68 | js,
69 | `slot.default( /*#__PURE__*/React3.createElement("default-content-wrapper", null));`,
70 | ),
71 | ).toBe(true);
72 | expect(
73 | includesLast(
74 | js,
75 | "/* slot transformation done by unplugin-transform-react-slots */",
76 | ),
77 | ).toBe(true);
78 |
79 | expect(
80 | includesLast(
81 | ts,
82 | `slot.default(React.createElement("default-content-wrapper", null)); `,
83 | ),
84 | ).toBe(true);
85 | expect(
86 | includesLast(
87 | ts,
88 | "/* slot transformation done by unplugin-transform-react-slots */",
89 | ),
90 | ).toBe(true);
91 | });
92 | });
93 |
--------------------------------------------------------------------------------
/docs/pages/tutorial/templates.mdx:
--------------------------------------------------------------------------------
1 | import { Callout } from "nextra/components";
2 |
3 | # Templates
4 |
5 | As you might have seen, there's a limitation when assigning elements to slots in
6 | a parent component using the `slot-name` attribute. Even simple content like
7 | strings or numbers needs to be wrapped in an element to use the `slot-name`
8 | attribute.
9 |
10 | Template elements offer an alternative way to specify content for slots without
11 | the need for the `slot-name` attribute. They work similarly to slot elements,
12 | where they are objects with keys corresponding to the slot names.
13 |
14 | Let's see how to use template elements to provide content to the `Button`
15 | component from one of our earlier examples:
16 |
17 | ```jsx
18 | import { template } from "@beqa/react-slots";
19 | import { Button } from "./Button.jsx";
20 |
21 | function SomeComponent() {
22 | return (
23 |
24 | Add item to my collection?
25 |
26 | Add
27 | +
28 |
29 |
30 | );
31 | }
32 | ```
33 |
34 | ## Accessing Child's Specified Props
35 |
36 | Templates can also perform an interesting trick. If the `children` of a template
37 | element is a function, it will be called with the props passed to its
38 | corresponding slot in the child component.
39 |
40 | In the [previous section](/tutorial/slot-and-has-slot#the-hasslot-object), we
41 | created the `ToggleButton` component, which passes a dynamic `isToggled` prop up
42 | to its parent. Here's how to access that prop in the parent with a template
43 | element:
44 |
45 | ```jsx filename="SomeOtherComponent.jsx"
46 |
47 |
48 | {(props) => (props.isToggled ? "On" : "Off")}
49 |
50 |
51 | ```
52 |
53 | Please note that just because a prop was passed to the slot in the child
54 | component doesn't mean you must use a template with a `children` function inside
55 | the parent. If you don't need to use the `isToggled` state in the parent, you
56 | can provide a static value with or without a template. You can even provide
57 | multiple contents for the same slot, some of which may or may not depend on the
58 | `isToggled` state.
59 |
60 | ```jsx filename="SomeOtherComponent.jsx"
61 |
62 | The toggle is:
63 |
64 | {(props) => (props.isToggled ? "On." : "Off.")}
65 |
66 | But I'm always on
67 |
68 | ```
69 |
70 |
71 | When dealing with `default` slots, there's no need to wrap the function in a
72 | `template.default` element to access the props passed to the corresponding
73 | slot. Regular functions within the `children` are also executed with the props
74 | passed to a `default` slot.
75 |
76 |
77 | ## TL;DR
78 |
79 | - Template elements offer an alternative way to specify slot content without
80 | using the `slot-name` attribute.
81 | - They work similarly to slot elements, with keys corresponding to slot names.
82 | - You can access child component specified slots within templates.
83 | - Using templates, you can provide both static and dynamic content for the same
84 | slot.
85 |
--------------------------------------------------------------------------------
/docs/pages/tutorial/slot-and-has-slot.mdx:
--------------------------------------------------------------------------------
1 | import { Callout } from "nextra/components";
2 |
3 | # Slot Elements and `hasSlot` Object
4 |
5 | Slot elements come with some convenient built-in functionality. On this page,
6 | we'll see how `react-slots` handles common patterns when dealing with a
7 | slot-based architecture.
8 |
9 | ## Specifying Fallback Content:
10 |
11 | Sometimes, you want to render a fallback in a slot even if the parent doesn't
12 | provide content for that slot. To achieve this, **simply include your fallback
13 | content as children of the slot element.**
14 |
15 | Consider a `listIcon` slot for the `BulletList` component. We might want to
16 | display a fallback list icon if the parent didn't provide a unique icon for this
17 | slot.
18 |
19 | ```jsx {9}
20 | function BulletList({ children, items }) {
21 | const { slot } = useSlot(children);
22 |
23 | return (
24 |
25 | {items.map((item) => (
26 |
27 | {/* This emoji will be rendered as a fallback for the listIcon slot */}
28 | 👉
29 | {item}
30 |
31 | ))}
32 |
33 | );
34 | }
35 | ```
36 |
37 | ## The hasSlot Object
38 |
39 | `type hasSlot = {[slotName: string]: true | undefined}{:ts}`.
40 |
41 | hasSlot is an object with keys representing slot names specified by the parent.
42 | For example, if a parent provides a `foo` slot, then `hasSlot.foo` in the child
43 | component will be true; otherwise, undefined. You can access hasSlot by
44 | destructuring the useSlot return value.
45 |
46 | hasSlot is useful when you want to style or structure the UI based on whether
47 | the parent provided content for a particular slot.
48 |
49 | For example, the `title` slot for the Card component might render a horizontal
50 | line after the card title only if the title is provided:
51 |
52 | ```jsx {2, 7}
53 | function Card() {
54 | const { slot, hasSlot } = useSlot();
55 |
56 | return (
57 |
58 |
59 | {hasSlot.title &&
}
60 | ...
61 |
62 | );
63 | }
64 | ```
65 |
66 | ## Passing State Up to a Parent
67 |
68 | If you want a parent to access an internal state just specify it on the slot
69 | element and parent will receive it. The following `ToggleButton` component keeps
70 | track of the `isToggled` state and passes it up to the parent so that the parent
71 | can change the text on the button based on whether the button is toggled or not.
72 | As a fallback, it renders "Enabled" and "Disabled" if the parent doesn't provide
73 | custom text.
74 |
75 | ```jsx {8}
76 | function ToggleButton({ children }) {
77 | const { slot } = useSlot(children);
78 | const [isToggled, setIsToggled] = useState(false);
79 |
80 | return (
81 | setIsToggled(!isToggled)}>
82 | {/* Pass the isToggled prop up to the parent*/}
83 |
84 | {/* Dynamic fallback content */}
85 | {isToggled ? "Enabled" : "Disabled"}
86 |
87 |
88 | );
89 | }
90 | ```
91 |
92 |
93 | We'll see how to access the `isToggled` state from the parent in the
94 | [templates](/tutorial/templates#accessing-childs-specified-props) section.
95 |
96 |
--------------------------------------------------------------------------------
/docs/pages/tutorial/manipulating-slot-content.mdx:
--------------------------------------------------------------------------------
1 | import { Callout } from "nextra/components";
2 |
3 | ## Manipulating Slot Content With `OverrideNode`
4 |
5 |
6 |
7 | If you've read the docs this far, I want to say thank you.
8 | I and ChatGPT engineers are very happy you're enjoying our work!
9 |
10 | At this point, it's a good time to take a break, sip some coffee, and perhaps
11 | play with the examples from the [introduction page](/introduction). The upcoming sections might
12 | get a bit confusing because they intersect with the knowledge you've gained so
13 | far.
14 |
15 |
16 |
17 |
18 | When you start using `react-slots`, you'll notice a new pattern emerging in your
19 | components. Instead of creating components with a massive list of props that
20 | control every aspect of the component's rendered content, you'll be drawn to
21 | creating components with "holes" meant to be filled with parent-provided
22 | **free-form content** and some logic that binds these holes or slots together.
23 | This is a good thing because it allows for a higher level of composability, and
24 | maintaining small pieces independently is much easier than maintaining giant
25 | components.
26 |
27 | Despite its benefits, parent-provided free-form content has a drawback: it's a
28 | black box. It might have any shape, its props, event listeners, or styles, and
29 | all of it is hidden away from you. This makes it challenging to integrate
30 | free-form content into the specific logic of your components.
31 |
32 | `OverrideNode` gives you direct access to the parent-provided nodes and exposes
33 | a convenient API to manipulate them right before they get rendered.
34 |
35 | You can use `OverrideNode` to:
36 |
37 | - Enforce node types.
38 | - Intercept element events and execute side-effects before or after their event
39 | handlers.
40 | - Add or change element props.
41 | - Add aria attributes for accessibility.
42 | - Change the nodes in any way.
43 |
44 | `OverrideNode` is just a React element. It can be included as a direct child of
45 | a slot element. When present, it has the ability to override parent-provided
46 | content for the slot. Additionally, if the slot content is not provided, the
47 | children within `OverrideNode` will act as a fallback content and the override
48 | will apply to them.
49 |
50 |
51 | `OverrideNode` always applies to the parent-provided content but only applies
52 | to the section of the fallback content wrapped by the `OverrideNode`.
53 |
54 |
55 | Let's take a look at some high-level examples before delving into the API
56 | documentation in the next sections:
57 |
58 | ```jsx
59 | import { OverrideNode } from "@beqa/react-slots";
60 |
61 | // Throw an error if the parent provides any element other than "h1", "h2", "h3".
62 | // The rule doesn't apply to fallback content.
63 |
64 |
65 |
66 |
Fallback
67 |
68 | ;
69 |
70 | // Remove any node other than a button from the parent's provided "trigger" slot content.
71 | // If the parent provides a button, intercept its onClick event.
72 | // The rule also applies to fallback, which is Trigger .
73 |
74 | alert("Button clicked")) }}
78 | >
79 | Trigger
80 |
81 | ;
82 | ```
83 |
--------------------------------------------------------------------------------
/examples/vite/src/App.css:
--------------------------------------------------------------------------------
1 | :root {
2 | color-scheme: only dark;
3 | font-family: "Roboto", sans-serif;
4 | }
5 |
6 | body {
7 | margin: 0;
8 | padding: 1.6rem;
9 | }
10 |
11 | * {
12 | box-sizing: border-box;
13 | }
14 |
15 | p {
16 | margin-top: 0.5em;
17 | margin-bottom: 0;
18 | }
19 |
20 | .ReactModal__Overlay {
21 | background-color: rgba(255, 255, 255, 0.08) !important;
22 | display: grid;
23 | place-items: center;
24 | padding: 1rem;
25 | }
26 |
27 | .demos > * {
28 | margin-bottom: 1rem;
29 | }
30 |
31 | .ReactModal__Content {
32 | position: unset !important;
33 | inset: unset !important;
34 | background-color: Canvas !important;
35 | color: CanvasText !important;
36 | border-radius: 1.5rem !important;
37 | border: 2px solid skyblue !important;
38 | padding: 1.5rem !important;
39 | width: 100%;
40 | max-width: 30rem;
41 | min-height: 15rem;
42 | display: grid;
43 | box-shadow: 0 2px 12px 5px rgba(0, 0, 0, 0.1);
44 | }
45 |
46 | .dialog {
47 | display: grid;
48 | grid-template-rows: auto 1fr auto;
49 | }
50 |
51 | .dialog > * + * {
52 | margin: 1em 0 0;
53 | }
54 |
55 | .dialog__title {
56 | font-size: 1.5rem;
57 | font-weight: normal;
58 | text-align: center;
59 | margin: 0;
60 | }
61 |
62 | .dialog__actions {
63 | justify-self: flex-end;
64 | display: flex;
65 | gap: 0.5rem;
66 | }
67 |
68 | .button {
69 | height: 2.5rem;
70 | min-width: 2.5rem;
71 | padding: 0 1rem;
72 | border-radius: 1.25rem;
73 | border: 0;
74 | box-shadow: 0 1px 5px 0 rgba(0, 0, 0, 0.2);
75 | text-transform: capitalize;
76 | display: inline-flex;
77 | align-items: center;
78 | gap: 0.5rem;
79 | font-size: 1rem;
80 | transition: all 0.125s ease-in-out;
81 | }
82 |
83 | .button__left {
84 | width: 1.5rem;
85 | display: inline-flex;
86 | justify-content: center;
87 | align-items: center;
88 | transform: translateX(-0.5rem);
89 | font-size: 1.5rem;
90 | }
91 |
92 | .button__right {
93 | width: 1.5rem;
94 | height: 1.5rem;
95 | display: inline-flex;
96 | justify-content: center;
97 | align-items: center;
98 | transform: translateX(0.5rem);
99 | font-size: 1.2rem;
100 | }
101 |
102 | .button--primary {
103 | background-color: skyblue;
104 | color: rgba(0, 0, 0, 0.8);
105 |
106 | &:hover {
107 | color: black;
108 | }
109 | }
110 |
111 | .button--secondary {
112 | background-color: transparent;
113 | opacity: 0.9;
114 |
115 | &:hover {
116 | opacity: 1;
117 | background-color: rgba(255, 255, 255, 0.08);
118 | }
119 | }
120 |
121 | .accordion-list {
122 | display: flex;
123 | flex-direction: column;
124 | gap: 0.5rem;
125 | max-width: 35rem;
126 | }
127 |
128 | .accordion {
129 | display: flex;
130 | flex-direction: column;
131 | }
132 |
133 | .accordion__summary {
134 | display: flex;
135 | justify-content: space-between;
136 | box-shadow: none;
137 | border-radius: 1rem;
138 | background-color: rgba(255, 255, 255, 0.08);
139 | font-weight: bold;
140 | }
141 |
142 | .accordion__icon {
143 | transform: rotate(0);
144 | transition: transform 0.12s ease-in-out;
145 | }
146 |
147 | .accordion__details {
148 | padding: 1rem;
149 | border-radius: 1.5rem;
150 | display: none;
151 | }
152 |
153 | .accordion--open {
154 | background-color: rgba(255, 255, 255, 0.12);
155 | color: black;
156 | border-radius: 1rem;
157 |
158 | & .accordion__summary {
159 | color: skyblue;
160 | }
161 |
162 | & .accordion__icon {
163 | transform: rotate(-90deg);
164 | }
165 |
166 | & .accordion__details {
167 | color: white;
168 | display: block;
169 | }
170 | }
171 |
--------------------------------------------------------------------------------
/packages/react-slots/src/useSlot.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Children from "./children";
3 | import type {
4 | SlotChildren,
5 | CreateSlot,
6 | SlotProps,
7 | SlotComponent,
8 | HasSlot,
9 | } from "./types";
10 | import { COMPONENT_TYPE } from "./constants";
11 | import { HiddenArg } from "./HiddenArg";
12 | import { OverrideConfig } from "./OverrideNode";
13 |
14 | class SlotProxyFactory {
15 | private children = new Children();
16 | private slotProxy: CreateSlot;
17 |
18 | constructor() {
19 | this.slotProxy = new Proxy({} as CreateSlot, {
20 | get: (target, property) => {
21 | if (
22 | Object.prototype.hasOwnProperty.call(target, property) ||
23 | typeof property === "symbol"
24 | ) {
25 | return Reflect.get(target, property);
26 | }
27 | const addedProperty = this.getSlotComponent(property);
28 | Reflect.set(target, property, addedProperty);
29 | return addedProperty;
30 | },
31 | });
32 | }
33 |
34 | private getSlotComponent(slotName: string): SlotComponent {
35 | const that = this;
36 |
37 | const SlotComponent: SlotComponent = Object.assign(
38 | (
39 | defaultNode?: React.ReactNode | SlotProps<{}>,
40 | props?: {},
41 | key?: React.Key,
42 | hiddenPreviousConfig?: HiddenArg,
43 | hiddenPreviousDefaultNode?: HiddenArg,
44 | ) => {
45 | if (
46 | !React.isValidElement(defaultNode) &&
47 | typeof defaultNode === "object" &&
48 | defaultNode !== null &&
49 | !(Symbol.iterator in defaultNode)
50 | ) {
51 | throw new Error(
52 | "To use slots as JSX elements, it's essential to enable `babel-plugin-transform-react-slots`. Alternatively, you can opt for the function signature instead.",
53 | );
54 | }
55 |
56 | let _props;
57 | let _slotNameAttr;
58 | let _key = key;
59 |
60 | if (props == undefined) {
61 | _props = {};
62 | } else if (typeof props !== "object") {
63 | throw new Error(
64 | "`props` must be an object, instead saw " + typeof props,
65 | );
66 | } else if ("key" in props || "slot-name" in props) {
67 | let { key, "slot-name": slotNameAttr, ...rest } = props as any;
68 | _props = rest;
69 | _slotNameAttr = slotNameAttr;
70 | _key = key;
71 | } else {
72 | _props = props;
73 | }
74 |
75 | return that.children.get(
76 | slotName,
77 | defaultNode,
78 | _props,
79 | _key,
80 | _slotNameAttr,
81 | hiddenPreviousConfig && hiddenPreviousConfig instanceof HiddenArg
82 | ? hiddenPreviousConfig.arg
83 | : [],
84 | hiddenPreviousDefaultNode &&
85 | hiddenPreviousDefaultNode instanceof HiddenArg
86 | ? hiddenPreviousDefaultNode.arg
87 | : [],
88 | );
89 | },
90 | {
91 | [COMPONENT_TYPE]: "slot" as const,
92 | },
93 | );
94 |
95 | return SlotComponent;
96 | }
97 |
98 | build(children: T): SlotProxyFactory {
99 | this.children.build(children);
100 | return this;
101 | }
102 |
103 | getHasSlot(): HasSlot {
104 | let hasSlot: { [index: string]: true | undefined } = {};
105 | for (const key of this.children.keys()) {
106 | hasSlot[key] = true;
107 | }
108 | return hasSlot as HasSlot;
109 | }
110 |
111 | getSlotProxy(): CreateSlot {
112 | return this.slotProxy;
113 | }
114 | }
115 |
116 | export function useSlot(
117 | children: T,
118 | ): { slot: CreateSlot; hasSlot: HasSlot } {
119 | const proxyCreator = new SlotProxyFactory();
120 |
121 | // const prevChildren = React.useRef();
122 | // if (prevChildren.current !== children) {
123 | proxyCreator.build(children);
124 | // }
125 | let proxy = proxyCreator.getSlotProxy();
126 | // prevChildren.current = children;
127 |
128 | return { slot: proxy, hasSlot: proxyCreator.getHasSlot() };
129 | }
130 |
--------------------------------------------------------------------------------
/docs/pages/advanced/composing-slotted-components.mdx:
--------------------------------------------------------------------------------
1 | # Composing Slotted Components
2 |
3 | Let's consider a slotted component called `Child`. The `Child` component has a
4 | `label` slot that accepts a `isSelected` prop. We'll use TypeScript for this
5 | example.
6 |
7 | ```tsx
8 | type Props = {
9 | children: SlotChildren>;
10 | };
11 |
12 | function Child({ children }: Props) {
13 | const { slot } = useSlot(children);
14 | return Label ;
15 | }
16 |
17 | const childTemplate = createTemplate();
18 | ```
19 |
20 | Now, we want to use the `Child` component within a `Parent` component. The
21 | `Parent` component has a `default` slot that, if provided, needs to go into the
22 | `Child` component's `label` slot. There are three ways to achieve this.
23 |
24 | **Option one:** Adding a `slot-name` attribute to the parent's slot.
25 |
26 | ```tsx
27 | function Parent({ children }) {
28 | const { slot } = useSlot(children);
29 | return (
30 |
31 | Label
32 |
33 | );
34 | }
35 | ```
36 |
37 | With this approach, when a consumer provides `default` slot content, it goes
38 | into the child's `label` slot. However, this approach has drawbacks:
39 |
40 | - The consumer can no longer access the `isSelected` prop.
41 | - If we don't specify fallback content inside the `Parent`, there will be no
42 | fallback.
43 |
44 | **Option two:** Using child's template to render own slot.
45 |
46 | ```tsx
47 | function Parent({ children }) {
48 | const { slot } = useSlot(children);
49 | return (
50 |
51 |
52 | {({ isSelected }) => }
53 |
54 |
55 | );
56 | }
57 | ```
58 |
59 | Now, a consumer can access the `isSelected` prop if desired, but the second
60 | problem still remains; the old fallback is gone.
61 |
62 | **Option three:** The "Template as Slot" pattern.
63 |
64 | ```tsx
65 | function Parent({ children }) {
66 | const { slot } = useSlot(children);
67 | return (
68 |
69 |
70 | {/* You can override the child's fallback here */}
71 |
72 |
73 | );
74 | }
75 | ```
76 |
77 | This is the most powerful and type-safe option. When you use this method:
78 |
79 | - The child's specified props are merged with the parent's specified props. If
80 | they specify the same prop, the parent's version will override the child's.
81 | - If no content is provided, the parent's fallback is rendered. If the parent
82 | did not provide a fallback, then the child's fallback is used.
83 | - TypeScript will raise an error if the parent's specified props do not extend
84 | the child's specified props.
85 | - **A function can no longer be used as the `children` of a template that has
86 | the `as` prop set to a slot.**
87 |
88 | **Something interesting also happens when this syntax is used with slots that
89 | have `OverrideNode`:**
90 |
91 | - If the parent's "template as slot" element includes `OverrideNode` in its
92 | children and the child's slot also has `OverrideNode`, then the parent's
93 | `OverrideNode` will apply to the provided content first, and its result will
94 | be passed to the child's `OverrideNode`.
95 | - If content isn't provided, then all of the child's `OverrideNode` logic will
96 | also apply to the parent's "template as slot" specified fallback content.
97 | Additionally, if the "template as slot" specified fallback content is wrapped
98 | in `OverrideNode`, the parent's `OverrideNode` logic will be applied first.
99 |
100 | ```jsx
101 | function Child({ children }) {
102 | const { slot } = useSlot(children);
103 |
104 | return (
105 |
106 | `${current} child-added` }} />
107 |
108 | );
109 | }
110 |
111 | function Parent({ children }) {
112 | const { slot } = useSlot(children);
113 |
114 | return (
115 |
116 | `${current} parent-added` }}>
117 | Parent's fallback
118 |
119 |
120 | );
121 | }
122 |
123 | // With provided content
124 |
125 | Provided content
126 | ;
127 | // Expected HTML output:
128 | Provided content
;
129 |
130 | // Without provided content
131 | ;
132 | // Expected HTML output:
133 | Parent's fallback
;
134 | ```
135 |
--------------------------------------------------------------------------------
/docs/pages/tutorial/type-safety.mdx:
--------------------------------------------------------------------------------
1 | import { Callout } from "nextra/components";
2 |
3 | # Type-Safety
4 |
5 | Type-safety is the most straightforward and powerful feature of `react-slots`.
6 | You're likely accustomed to annotating your `children` as `ReactNode`, but in
7 | `react-slots`, you should use two special types `SlotChildren` and `Slot` to
8 | annotate your `children`, and everything else will be automatically inferred.
9 |
10 | ## Implementing a Type-Safe Component
11 |
12 | Let's see how to create a fully type-safe component in `react-slots` and delve
13 | into the details later. This type-safe `Dialog` component has two slots:
14 |
15 | - `trigger`: A label for the button that opens the dialog.
16 | - `default`: Content that becomes visible after the user clicks the trigger
17 | button. It passes two props to its parent, `isOpen: boolean{:ts}` and
18 | `close: () => void{:ts}`. A parent can access these props with a `default`
19 | template.
20 |
21 | ```tsx {10-12, 16,32} filename="Dialog.tsx"
22 | import {
23 | SlotChildren,
24 | Slot,
25 | CreateTemplate,
26 | useSlot,
27 | template,
28 | } from "@beqa/react-slots";
29 |
30 | type Props = {
31 | children: SlotChildren<
32 | Slot<"trigger"> | Slot<{ isOpen: boolean; close: () => void }>
33 | >;
34 | };
35 |
36 | function Dialog({ children }: Props) {
37 | const { slot } = useSlot(children); // Inferred automatically
38 | const [isOpen, setIsOpen] = useState(false);
39 |
40 | return (
41 | <>
42 | setIsOpen(true)}>
43 | Trigger Dialog
44 |
45 | {isOpen && (
46 | setIsOpen(false)} />
47 | )}
48 | >
49 | );
50 | }
51 |
52 | // Create type-safe template specifically for dialog component
53 | const dialogTemplate = template as CreateTemplate;
54 |
55 | // Usage example
56 |
57 | Open Dialog
58 | {/* No need to wrap in dialogTemplate.default, it's still type-safe */}
59 | {(props) => (
60 |
61 |
This is a dialog that opens after you click "Open Dialog"
62 | Close
63 |
64 | )}
65 | ;
66 | ```
67 |
68 | ## The `SlotChildren` type
69 |
70 | `type SlotChildren{:ts}` is a generic type that expects a union of `Slot` types
71 | as its only type parameter. It enforces that the `children` of this component
72 | should be nodes of the type described by the `Slot` types. Note that `Slot`
73 | types, independent of `SlotChildren`, have no purpose.
74 |
75 | ## The `Slot` type
76 |
77 | `type Slot{:ts}` is a generic type used to specify the slot name and props. It
78 | comes in multiple forms:
79 |
80 | - Two type parameters: `Slot<"foo", { bar: number }>{:ts}`. The first parameter
81 | specifies the slot name, and the second specifies the slot props.
82 | - String as the only type parameter: `Slot<"foo">{:ts}`. This is a shorthand for
83 | `Slot<"foo", {}>{:ts}`, useful when you only want to specify the slot name.
84 | - Object as the only type parameter: `Slot<{ baz: boolean }>{:ts}`. This is a
85 | shorthand for `Slot<"default", { baz: boolean }>{:ts}`, used to specify props
86 | for the `default` slot.
87 | - No type parameters: `Slot{:ts}`. This is a shorthand for
88 | `Slot<"default", {}>{:ts}`, used for specifying the `default` slot with no
89 | props.
90 |
91 | The `children` type annotation for the above `Dialog` component states that it
92 | expects at least one node as a child, which can be:
93 |
94 | - An element or component with the `slot-name="trigger"` attribute or a
95 | `template.trigger` element.
96 | - Any node, including strings, numbers, elements or components without a
97 | `slot-name` attribute or with a `slot-name: "default"` attribute, or a
98 | `template.default` element that receives the `isOpen` and `close` props.
99 |
100 | ## The `CreateTemplate` Type
101 |
102 | `type CreateTemplate{:ts}` is a utility type that makes
103 | templates type-safe. It expects the same `children` type as the component it
104 | belongs to. Template objects typed with `CreateTemplate` only let you to use
105 | predefined slot names and automatically provide type hints for props.
106 |
107 | You can also create type-safe templates using the `createTemplate` function:
108 |
109 | ```ts
110 | import { createTemplate } from "@beqa/react-slots";
111 | const fooTemplate = createTemplate();
112 | ```
113 |
114 |
115 | Recommendation #1: Define and export type-safe templates in the same file as
116 | their components.
117 |
118 |
119 |
120 | Recommendation #2: Be cautious when changing slot names during refactoring.
121 | TypeScript won't catch if you specify the wrong name with the slot-name
122 | attribute, but it will catch type-related errors when type-safe templates are
123 | used.
124 |
125 |
--------------------------------------------------------------------------------
/docs/pages/tutorial/overriding-props.mdx:
--------------------------------------------------------------------------------
1 | import { Callout } from "nextra/components";
2 |
3 | # Overriding Props
4 |
5 | Another useful feature is overriding the props of nodes that are allowed. To
6 | achieve this, you can specify the `props` property on the `OverrideNode`
7 | element.
8 |
9 | The `props` can be specified in two ways:
10 |
11 | - As a function that receives all the props of a node and is expected to return
12 | an object that will be shallow merged with the original props object.
13 | - As an object where the keys are prop names, and the values are functions.
14 | These functions receive the current value for the props they are overriding
15 | and are expected to return the overridden value.
16 |
17 | ## Prop Function
18 |
19 | Here's an example with an `Edit` element that adds a `contentEditable` attribute
20 | to every valid element in its children that has a `data-editable` attribute:
21 |
22 | ```jsx {7-9}
23 | function Edit({ children, enabled }) {
24 | const { slot } = useSlot(children);
25 |
26 | return (
27 |
28 | ({
30 | contenteditable: enabled && props["data-editable"] ? "true" : "false",
31 | })}
32 | >
33 | {enabled && "Start editing me"}
34 |
35 |
36 | );
37 | }
38 |
39 | // Usage
40 |
41 | You can edit me
42 | You can't edit me
43 | Can't edit me either
44 | ;
45 |
46 | // This is also editable because of the fallback content
47 | ;
48 | ```
49 |
50 | ## Props Object
51 |
52 | Here's an example using an alternative syntax for `props`:
53 |
54 | ```jsx {4-9}
55 |
56 | current == undefined ? "added-class" : `${current} added-class`,
61 | // Provide a default click handler if none is provided
62 | onClick: (current) => current || () => alert("Clicked!"),
63 | }}
64 | >
65 | Trigger
66 |
67 |
68 | ```
69 |
70 |
71 | Note: The `props` logic will only be executed for valid React elements that
72 | satisfy the constraints of `allowedNodes`. For instance, if your
73 | `allowedNodes` includes values like `[String, Number, "button", MyComponent]`,
74 | the `props` function will only be called with the props of `'button'` and
75 | `MyComponent`.
76 |
77 |
78 | ## Built-in Prop Override Helpers
79 |
80 | `react-slots` includes built-in functions to assist you with common prop
81 | override operations. These functions are accessible directly from the
82 | `OverrideNode` object and are designed to be used in the `props` object of
83 | `OverrideNode`.
84 |
85 | - `OverrideNode.stringAppend`: Appends a string value to an existing prop with a
86 | space in between. If the existing prop is `null` or `undefined`, the new value
87 | will replace the old one. If the existing prop is not a string, it will be
88 | converted to a string. This is handy for adding to `className` or `id` without
89 | discarding the existing value.
90 | - `OverrideNode.stringPrepend`: Prepends a string value to an existing prop with
91 | a space. If the existing prop is `null` or `undefined`, the new value will
92 | replace the old one. If the existing prop is not a string, it will be
93 | converted to a string. This is useful for adding to the beginning of
94 | `className` or `id` without losing the existing value.
95 | - `OverrideNode.override`: Replaces the old prop with a new value.
96 | - `OverrideNode.chainAfter`: Takes a function as an argument and calls the
97 | provided function after the original function with the original arguments. If
98 | the original function is `undefined`, only the new function will be called. If
99 | the original value is not a function, an error is thrown. This is helpful for
100 | performing a side effect after an event without losing the original handler.
101 | - `OverrideNode.chainBefore`: Takes a function as an argument and calls the
102 | provided function before the original function with the original arguments. If
103 | the original function is `undefined`, only the new function will be called. If
104 | the original value is not a function, an error is thrown. This is useful for
105 | performing a side effect before an event without losing the original handler.
106 |
107 | **Example:**
108 |
109 | ```jsx
110 |
111 | alert("After click!")),
117 | onKeyDown: OverrideNode.chainBefore((e) => {
118 | alert(e.key);
119 | }),
120 | type: OverrideNode.override("submit"),
121 | }}
122 | />
123 |
124 | ```
125 |
--------------------------------------------------------------------------------
/docs/pages/tutorial/slot-pattern.mdx:
--------------------------------------------------------------------------------
1 | import { Callout } from "nextra/components";
2 |
3 |
4 |
5 | Optimizing Your Documentation Experience: The tutorial is intended to be
6 | read sequentially, with each section building upon the previous one. Once
7 | you have completed the tutorial, feel free to explore the
8 | [advanced](/advanced/multiple-override-node) documentation in any order you
9 | prefer.
10 |
11 |
12 |
13 | # The Slot Pattern
14 |
15 | The slot pattern enables you to break down parent-provided content into multiple
16 | parts and instruct a component on where to render each part by placing slots.
17 |
18 | In a typical React component, this is accomplished using 'render props.'
19 | Components receive different parts of content as props and render each one in
20 | their specified slots. For instance, a typical `Button` component might accept
21 | **children**, **leftIcon**, and **rightIcon** props, rendering them in their
22 | predetermined places if provided.
23 |
24 | With `react-slots`, we don't use props to define slot content; everything is
25 | managed through `children`.
26 |
27 | ## Writing Slotted Components
28 |
29 | To begin placing slots, you need to use the `useSlot` hook. `useSlot` takes in
30 | the children and returns a `slot` object for this component. `slot` is a dynamic
31 | object, where any key gives you access to corresponding parent-provided content.
32 | Values on the `slot` object are just like any other React element. You can place
33 | these elements wherever you want inside the component.
34 |
35 | Let's implement the `Button` component using `react-slots`:
36 |
37 | ```jsx
38 | import { useSlot } from "@beqa/react-slots";
39 |
40 | function Button({ children, onClick }) {
41 | const { slot } = useSlot(children);
42 |
43 | return (
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | );
54 | }
55 | ```
56 |
57 | Here, you can see that we divided the children into three parts: `leftIcon`,
58 | `rightIcon`, and `default`. This means the parent now has to somehow specify
59 | contents for each of these to replace the slots inside the component.
60 |
61 | ## Using Slotted Components
62 |
63 | When passing children to this component, you need a way to designate which
64 | elements are for the `leftIcon`, `rightIcon`, and `default` slots. You achieve
65 | this by adding a `slot-name` attribute to any element or component passed to the
66 | `Button` as children (note: elements marked with `slot-name` must be direct
67 | children of the `Button`). **The `default` slot is special because any top-level
68 | node lacking a `slot-name` attribute automatically lands in the `default`
69 | slot.**
70 |
71 | Let's create an "[ Add + ]" button using this component:
72 |
73 | ```jsx
74 | import Button from "./Button.jsx";
75 |
76 | function SomeComponent() {
77 | return (
78 |
79 | Add item to My collection?
80 |
81 | Add
82 |
83 | +
84 |
85 |
86 |
87 | );
88 | }
89 | ```
90 |
91 | The rendered HTML from this component will look like this:
92 |
93 | ```jsx
94 |
95 | Add item to My collection?
96 |
97 |
98 | Add
99 |
100 | +
101 |
102 |
103 |
104 | ```
105 |
106 | As you can see, some interesting things have happened here:
107 |
108 | - The slot for `leftIcon` didn't render anything because it wasn't specified by
109 | the parent.
110 | - The slot for `rightIcon` rendered the parent's `span` and removed the
111 | `slot-name` attribute from the final HTML output.
112 | - The default slot rendered a string "Add" implicitly because it did not have
113 | the "slot-name" attribute.
114 |
115 |
116 | There are two more ways to specify slot content from the parent which are
117 | explored in detail in their dedicated sections:
118 | [Templates](/tutorial/templates), [Type-safe
119 | templates](/tutorial/type-safety/#the-createtemplate-type)
120 |
121 |
122 | ## Why not just use props?
123 |
124 | - Slots offer a convenient syntactic sugar for providing fallbacks and passing
125 | the props up.
126 | - `react-slots` draws inspiration from
127 | [Web components](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/slot),
128 | which is HTML's proposed standard for creating HTML-like elements. With
129 | `react-slots`, you can design components that function similarly to native
130 | HTML elements.
131 | - By enforcing the restriction that `children` is for specifying content and
132 | props are for modifying content, `react-slots` allows you to grant parents the
133 | freedom to specify free-form content and own their own markup. Simultaneously,
134 | it enables you to enforce strict relationships for composed elements with
135 | [OverrideNode](/tutorial/manipulating-slot-content).
136 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # beqa/react-slots - Responsible React Parenting
2 |
3 | `react-slots` empowers you to prioritize composability in your component APIs.
4 |
5 | ## Featuring
6 |
7 | - [Lightweight](https://bundlephobia.com/package/@beqa/react-slots) (< 8KB
8 | minified, < 3KB minified & gzipped)
9 | - Composability with ease
10 | - Type-safety
11 | - Server Components support
12 | - Not implemented with context
13 | - Intuitive API
14 | - Self-documenting with typescript
15 | - Elegant solution to a11y attributes
16 | - Inversion of control
17 |
18 | ## Installation
19 |
20 | The installation process consists of two parts: installing the **core library**
21 | (around **3KB gzipped** piece of code that runs in your users' browsers and
22 | handles the core logic) and an optional **compile-time plugin** (for transpiling
23 | JSX syntax for your slot elements into regular function invocations).
24 |
25 | [Installation steps](https://slots.beqa.site/installation)
26 |
27 | ## Docs
28 |
29 | You can find the docs on the
30 | [docs website](https://react-slots-docs.vercel.app/)
31 |
32 | ## Discord
33 |
34 | If you need any assistance, feel free to join our
35 | [Discord server](https://discord.gg/UHgArvjeNb)
36 |
37 | ## Implementing
38 |
39 | ```tsx
40 | import { useSlot, SlotChildren, Slot } from "@beqa/react-slots";
41 |
42 | type ListItemProps = {
43 | children: SlotChildren<
44 | | Slot<"title"> // Shorthand of Slot<"title", {}>
45 | | Slot<"thumbnail"> // Shorthand of Slot<"thumbnail", {}>
46 | | Slot<{ isExpanded: boolean }> // Shorthand of Slot<"default", {isExpanded: boolean}>
47 | >;
48 | };
49 |
50 | function ListItem({ children }: ListItemProps) {
51 | const { slot } = useSlot(children);
52 | const [isExpanded, setIsExpanded] = useState();
53 |
54 | return (
55 | setIsExpanded(!isExpanded)}
58 | >
59 | {/* Render thumbnail if provided, otherwise nothing*/}
60 |
61 |
62 | {/* Render a fallback if title is not provided*/}
63 | Expand for more
64 | {/* Render the description and pass the prop up to the parent */}
65 |
66 |
67 |
68 | );
69 | }
70 | ```
71 |
72 | ## Specifying Slot Content From the Parent
73 |
74 | With `slot-name` attribute
75 |
76 | ```jsx
77 |
78 |
79 | A title
80 | this is a description
81 |
82 | ```
83 |
84 | With Templates
85 |
86 | ```jsx
87 | import { template } from "beqa/react-slots";
88 |
89 |
90 |
91 |
92 |
93 | A title
94 |
95 | {({ isExpanded }) =>
96 | isExpanded ? A description : "A description"
97 | }
98 |
99 | doesn't have to be a function
100 | ;
101 | ```
102 |
103 | With type-safe templates
104 |
105 | ```tsx
106 | // Option #1
107 | import { createTemplate } from "@beqa/react-slots";
108 | const template = createTemplate();
109 |
110 | // Option #2
111 | import { template, CreateTemplate } from "@beqa/react-slots";
112 | const template = template as CreateTemplate;
113 |
114 | // Typo-free and auto-complete for props!
115 |
116 |
117 |
118 |
119 | A title
120 |
121 | {({ isExpanded }) =>
122 | isExpanded ? A description : "A description"
123 | }
124 |
125 | doesn't have to be a function
126 | ;
127 | ```
128 |
129 | ## Advanced Examples
130 |
131 | | The code samples below represent actual implementations. No need to define external state or event handlers for these components to function. |
132 | | --------------------------------------------------------------------------------------------------------------------------------------------- |
133 |
134 | ### Creating highly composable `Accordion` and `AccordionList` components using react-slots
135 |
136 | Checkout
137 | [live example](https://stackblitz.com/edit/stackblitz-starters-tq32ef?file=pages%2Findex.tsx)
138 |
139 | ```jsx
140 |
141 |
142 | First Accordion
143 | This part of Accordion is hidden
144 |
145 |
146 | Second Accordion
147 | AccordionList makes it so that only one Accordion is open at a time
148 |
149 |
150 | Third Accordion
151 | No external state required
152 |
153 |
154 | ```
155 |
156 | ### Creating highly composable `Dialog` and `DialogTrigger` components using react-slots
157 |
158 | Checkout
159 | [live example](https://stackblitz.com/edit/stackblitz-starters-fa5wbe?file=pages%2Findex.tsx)
160 |
161 | ```jsx
162 |
163 | Trigger Dialog
164 |
165 | Look Ma, No External State
166 | ... And no event handlers.
167 | Closes automatically on button click.
168 | Can work with external state if desired.
169 | alert("But how are the button variants different?")}
172 | >
173 | Close??
174 |
175 | Close!
176 |
177 |
178 | ```
179 |
180 | If you like this project please show support by starring it on
181 | [Github](https://github.com/Flammae/react-slots)
182 |
--------------------------------------------------------------------------------
/packages/react-slots/README.md:
--------------------------------------------------------------------------------
1 | # beqa/react-slots - Responsible React Parenting
2 |
3 | `react-slots` empowers you to prioritize composability in your component APIs.
4 |
5 | ## Featuring
6 |
7 | - [Lightweight](https://bundlephobia.com/package/@beqa/react-slots) (< 8KB
8 | minified, < 3KB minified & gzipped)
9 | - Composability with ease
10 | - Type-safety
11 | - Server Components support
12 | - Not implemented with context
13 | - Intuitive API
14 | - Self-documenting with typescript
15 | - Elegant solution to a11y attributes
16 | - Inversion of control
17 |
18 | ## Installation
19 |
20 | The installation process consists of two parts: installing the **core library**
21 | (around **3KB gzipped** piece of code that runs in your users' browsers and
22 | handles the core logic) and an optional **compile-time plugin** (for transpiling
23 | JSX syntax for your slot elements into regular function invocations).
24 |
25 | [Installation steps](https://slots.beqa.site/installation)
26 |
27 | ## Docs
28 |
29 | You can find the docs on the
30 | [docs website](https://react-slots-docs.vercel.app/)
31 |
32 | ## Discord
33 |
34 | If you need any assistance, feel free to join our
35 | [Discord server](https://discord.gg/UHgArvjeNb)
36 |
37 | ## Implementing
38 |
39 | ```tsx
40 | import { useSlot, SlotChildren, Slot } from "@beqa/react-slots";
41 |
42 | type ListItemProps = {
43 | children: SlotChildren<
44 | | Slot<"title"> // Shorthand of Slot<"title", {}>
45 | | Slot<"thumbnail"> // Shorthand of Slot<"thumbnail", {}>
46 | | Slot<{ isExpanded: boolean }> // Shorthand of Slot<"default", {isExpanded: boolean}>
47 | >;
48 | };
49 |
50 | function ListItem({ children }: ListItemProps) {
51 | const { slot } = useSlot(children);
52 | const [isExpanded, setIsExpanded] = useState();
53 |
54 | return (
55 | setIsExpanded(!isExpanded)}
58 | >
59 | {/* Render thumbnail if provided, otherwise nothing*/}
60 |
61 |
62 | {/* Render a fallback if title is not provided*/}
63 | Expand for more
64 | {/* Render the description and pass the prop up to the parent */}
65 |
66 |
67 |
68 | );
69 | }
70 | ```
71 |
72 | ## Specifying Slot Content From the Parent
73 |
74 | With `slot-name` attribute
75 |
76 | ```jsx
77 |
78 |
79 | A title
80 | this is a description
81 |
82 | ```
83 |
84 | With Templates
85 |
86 | ```jsx
87 | import { template } from "beqa/react-slots";
88 |
89 |
90 |
91 |
92 |
93 | A title
94 |
95 | {({ isExpanded }) =>
96 | isExpanded ? A description : "A description"
97 | }
98 |
99 | doesn't have to be a function
100 | ;
101 | ```
102 |
103 | With type-safe templates
104 |
105 | ```tsx
106 | // Option #1
107 | import { createTemplate } from "@beqa/react-slots";
108 | const template = createTemplate();
109 |
110 | // Option #2
111 | import { template, CreateTemplate } from "@beqa/react-slots";
112 | const template = template as CreateTemplate;
113 |
114 | // Typo-free and auto-complete for props!
115 |
116 |
117 |
118 |
119 | A title
120 |
121 | {({ isExpanded }) =>
122 | isExpanded ? A description : "A description"
123 | }
124 |
125 | doesn't have to be a function
126 | ;
127 | ```
128 |
129 | ## Advanced Examples
130 |
131 | | The code samples below represent actual implementations. No need to define external state or event handlers for these components to function. |
132 | | --------------------------------------------------------------------------------------------------------------------------------------------- |
133 |
134 | ### Creating highly composable `Accordion` and `AccordionList` components using react-slots
135 |
136 | Checkout
137 | [live example](https://stackblitz.com/edit/stackblitz-starters-tq32ef?file=pages%2Findex.tsx)
138 |
139 | ```jsx
140 |
141 |
142 | First Accordion
143 | This part of Accordion is hidden
144 |
145 |
146 | Second Accordion
147 | AccordionList makes it so that only one Accordion is open at a time
148 |
149 |
150 | Third Accordion
151 | No external state required
152 |
153 |
154 | ```
155 |
156 | ### Creating highly composable `Dialog` and `DialogTrigger` components using react-slots
157 |
158 | Checkout
159 | [live example](https://stackblitz.com/edit/stackblitz-starters-fa5wbe?file=pages%2Findex.tsx)
160 |
161 | ```jsx
162 |
163 | Trigger Dialog
164 |
165 | Look Ma, No External State
166 | ... And no event handlers.
167 | Closes automatically on button click.
168 | Can work with external state if desired.
169 | alert("But how are the button variants different?")}
172 | >
173 | Close??
174 |
175 | Close!
176 |
177 |
178 | ```
179 |
180 | If you like this project please show support by starring it on
181 | [Github](https://github.com/Flammae/react-slots)
182 |
--------------------------------------------------------------------------------
/docs/pages/installation.mdx:
--------------------------------------------------------------------------------
1 | import { Callout } from "nextra/components";
2 |
3 | # Installation
4 |
5 | The installation process consists of two parts: installing the **core library**
6 | (around **3KB gzipped** piece of code that runs in your users' browsers and
7 | handles the core logic) and an optional **compile-time plugin** (for transpiling
8 | JSX syntax for your slot elements into regular function invocations).
9 |
10 | ## Install the Core Library
11 |
12 | ```bash
13 | npm i @beqa/react-slots
14 | ```
15 |
16 | ## Install the Compile-Time Plugin (Optional)
17 |
18 | The `transform-react-slots` plugin is necessary to transform slot elements
19 | returned by `useSlot()` into function invocations, as demonstrated below:
20 |
21 | ```jsx
22 | // Before transpilation
23 |
24 | Fallback
25 | ;
26 | // After transpilation
27 | slot.default("Fallback", { prop1: "foo", prop2: 42 });
28 | ```
29 |
30 | To install the compile-time plugin, check which build tool is used in your
31 | project and **follow the specific instructions provided for that tool**. Many
32 | projects use Webpack and Babel, but other projects might utilize different tools
33 | such as Vite, esbuild, or Rollup.
34 |
35 |
36 | Note: Installing the compile-time plugin is recommended, but you have the
37 | option to skip it and start using slots as functions immediately.
38 |
39 |
40 |
41 | Nextjs
42 |
43 | Install the core library if you haven't already
44 |
45 | ```bash
46 | npm i @beqa/react-slots
47 | ```
48 |
49 | Install `@beqa/unplugin-transform-react-slots`
50 |
51 | ```bash
52 | npm i -D @beqa/unplugin-transform-react-slots
53 | ```
54 |
55 | Import and add webpack plugin to the plugins list
56 |
57 | ```js
58 | const { default: unplugin } = require("@beqa/unplugin-transform-react-slots");
59 |
60 | const nextConfig = {
61 | webpack(config) {
62 | // Add this line
63 | config.plugins.unshift(unplugin.webpack());
64 | // Don't forget to return config
65 | return config;
66 | },
67 | };
68 |
69 | module.exports = nextConfig;
70 | ```
71 |
72 |
73 |
74 |
75 | CRA
76 |
77 | If you have an un-ejected Create React App project and want to keep it that way,
78 | we recommend using Craco. Craco allows you to override Create React App's
79 | configuration without ejecting. You can read about
80 | [how to start using Craco and the risks associated with it](https://craco.js.org/docs/).
81 | If your project is ejected, follow the instructions for configuring
82 | `react-slots` with Babel.
83 |
84 | Install the core library if you haven't already
85 |
86 | ```bash
87 | npm i @beqa/react-slots
88 | ```
89 |
90 | Install `@beqa/unplugin-transform-react-slots`
91 |
92 | ```bash
93 | npm i -D @beqa/unplugin-transform-react-slots
94 | ```
95 |
96 | Add a `craco.config.js` file in the root of your project and include the Webpack
97 | plugin:
98 |
99 | ```js
100 | const { default: unplugin } = require("@beqa/unplugin-transform-react-slots");
101 |
102 | module.exports = {
103 | webpack: {
104 | plugins: { add: [unplugin.webpack()] },
105 | },
106 | };
107 | ```
108 |
109 |
110 |
111 |
112 | Vite
113 |
114 | Install the core library if you haven't already
115 |
116 | ```bash
117 | npm i @beqa/react-slots
118 | ```
119 |
120 | Install `@beqa/unplugin-transform-react-slots`
121 |
122 | ```bash
123 | npm i -D @beqa/unplugin-transform-react-slots
124 | ```
125 |
126 | Add `unplugin.vite` to your`vite.config.js` before the react plugin:
127 |
128 | ```js
129 | import unplugin from "@beqa/unplugin-transform-react-slots";
130 | import react from "@vitejs/plugin-react";
131 |
132 | export default {
133 | // Make sure unplugin.vite is specified before react in your plugins list
134 | plugins: [unplugin.vite(), react()],
135 | };
136 | ```
137 |
138 |
139 |
140 |
141 | esbuild
142 |
143 | Install the core library if you haven't already
144 |
145 | ```bash
146 | npm i @beqa/react-slots
147 | ```
148 |
149 | Install `@beqa/unplugin-transform-react-slots`
150 |
151 | ```bash
152 | npm i -D @beqa/unplugin-transform-react-slots
153 | ```
154 |
155 | Add `unplugin.esbuild` to your plugins list in your esbuild config
156 |
157 | ```js
158 | import unplugin from "@beqa/unplugin-transform-react-slots";
159 |
160 | await build({
161 | plugins: [unplugin.esbuild()],
162 | });
163 | ```
164 |
165 |
166 |
167 |
168 | Rollup
169 |
170 | Install the core library if you haven't already
171 |
172 | ```bash
173 | npm i @beqa/react-slots
174 | ```
175 |
176 | Install `@beqa/unplugin-transform-react-slots`
177 |
178 | ```bash
179 | npm i -D @beqa/unplugin-transform-react-slots
180 | ```
181 |
182 | Add the `unplugin.rollup` to your plugins list before syntax transformation
183 | plugins in your `rollup.config.js`:
184 |
185 | ```js
186 | import unplugin from "@beqa/unplugin-transform-react-slots";
187 |
188 | export default {
189 | ...
190 | plugins: [
191 | unplugin.rollup(),
192 | // ... other plugins
193 | ]
194 | }
195 | ```
196 |
197 |
198 |
199 |
200 | Babel
201 |
202 | Install the core library if you haven't already
203 |
204 | ```bash
205 | npm i @beqa/react-slots
206 | ```
207 |
208 | Install `@beqa/babel-plugin-transform-react-slots`
209 |
210 | ```bash
211 | npm i -D @beqa/babel-plugin-transform-react-slots
212 | ```
213 |
214 | Add the plugin to your `.babelrc` file.
215 |
216 | ```json
217 | {
218 | "plugins": ["@beqa/babel-plugin-transform-react-slots"]
219 | }
220 | ```
221 |
222 |
223 |
224 |
225 | Performance Optimization with Unplugin Options
226 |
227 | This section is only relevant to you if you've been instructed to install
228 | `@beqa/unplugin-transform-react-slots` for your build tool.
229 |
230 | ```tsx
231 | type Options = {
232 | include: RegEx;
233 | exclude: RegEx | RegEx[];
234 | };
235 |
236 | const options = {
237 | include: /\.(tsx)|(jsx)|(js)/,
238 | } satisfies Options;
239 |
240 | unplugin.yourBundler(options);
241 | ```
242 |
243 | `unplugin-transform-react-slots` is designed to be fast at finding and
244 | transforming React slots. By default, it checks every JavaScript (js), JSX
245 | (jsx), and TypeScript (tsx) file in your project, excluding files in the
246 | node_modules directory. However, you can optimize its performance further by
247 | using specific options.
248 |
249 | **include Option**
250 |
251 | If you have other tools configured in a way that JSX syntax is only used in
252 | certain files, you can provide the include regular expression (RegEx) as an
253 | argument to your plugin. For instance:
254 |
255 | ```tsx
256 | unplugin.yourBundler({ include: /\.(tsx)|(jsx)/ });
257 | ```
258 |
259 | With this configuration, the plugin will only check .tsx and .jsx files in your
260 | project, improving performance by skipping unnecessary files.
261 |
262 | **exclude Option**
263 |
264 | Additionally, you can use the `exclude` option to exclude specific files or
265 | directories from being processed. This can be useful for excluding configuration
266 | files or large files that don't need slot transformation:
267 |
268 |
269 |
--------------------------------------------------------------------------------
/packages/react-slots/src/children.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import type { SlotChildren } from "./types";
3 | import {
4 | isNamedSlot,
5 | isTemplateElement,
6 | isTemplateComponent,
7 | isSlotComponent,
8 | } from "./typeGuards";
9 | import { DEFAULT_SLOT_NAME, DEFAULT_TEMPLATE_AS, SLOT_NAME } from "./constants";
10 | import { forEachNode, shouldDiscard } from "./forEachNode";
11 | import {
12 | OverrideConfig,
13 | applyOverride,
14 | applyOverrideToAll,
15 | extractOverrideConfig,
16 | } from "./OverrideNode";
17 | import { HiddenArg } from "./HiddenArg";
18 | import { template } from "./template";
19 |
20 | const Slot = (props: any) => props.children;
21 | function createSlotElement(
22 | children: React.ReactNode,
23 | key: React.Key | undefined,
24 | slotNameAttr: string | undefined,
25 | // Wrapper was initially a fragment but you can't have "slot-name" attr on it
26 | Wrapper: React.ElementType = Slot,
27 | ) {
28 | const props = {
29 | "slot-name": slotNameAttr,
30 | key: key,
31 | };
32 | if (key === undefined) delete props.key;
33 | if (slotNameAttr === undefined) delete props["slot-name"];
34 |
35 | return React.createElement(Wrapper, props, children);
36 | }
37 |
38 | function validateProps(props: {}) {
39 | if (props.hasOwnProperty("as")) {
40 | throw new Error("slot cannot have `as` property");
41 | }
42 |
43 | if (props.hasOwnProperty("children")) {
44 | throw new Error(
45 | "slot cannot have `children` property. Specify the children for fallback separately",
46 | );
47 | }
48 |
49 | if (props.hasOwnProperty("ref")) {
50 | throw new Error("slot cannot have ref");
51 | }
52 | }
53 |
54 | // function createTemplateElement
55 |
56 | export default class Children {
57 | private children = new Map<
58 | string,
59 | {
60 | nodes: Exclude>[];
61 | hasTemplate: boolean;
62 | }
63 | >();
64 |
65 | private set(
66 | slotName: string,
67 | node: Exclude>,
68 | ): void {
69 | if (!this.children.has(slotName)) {
70 | this.children.set(slotName, {
71 | nodes: [],
72 | hasTemplate: false,
73 | });
74 | }
75 |
76 | const child = this.children.get(slotName)!;
77 |
78 | if (isTemplateElement(node)) {
79 | child.hasTemplate = true;
80 | }
81 |
82 | child.nodes.push(node);
83 | }
84 |
85 | build(children: SlotChildren): void {
86 | this.children.clear();
87 |
88 | forEachNode(children, (child) => {
89 | const isValidElement = React.isValidElement(child);
90 |
91 | if (isValidElement && isTemplateComponent(child.type)) {
92 | //
93 | this.set(child.type[SLOT_NAME], child);
94 | } else if (isValidElement && isNamedSlot(child)) {
95 | //
96 | const newProps: Partial = Object.assign(
97 | child.key ? { key: child.key } : {},
98 | child.props,
99 | );
100 | delete newProps["slot-name"];
101 |
102 | const newElement = React.createElement(child.type, newProps);
103 | this.set(child.props["slot-name"], newElement);
104 | } else if (typeof child === "function") {
105 | // (props) =>
106 | this.set(
107 | DEFAULT_SLOT_NAME,
108 | // We need to wrap functions in template elements so that later React.Children.map can operate on it
109 | {child} ,
110 | );
111 | } else {
112 | //
, "foo", 42
113 | this.set(DEFAULT_SLOT_NAME, child);
114 | }
115 | // true, false, null, undefined is removed by forEachSlot
116 | });
117 | }
118 |
119 | get(
120 | slotName: string,
121 | defaultContent: React.ReactNode,
122 | props: {},
123 | slotKey: React.Key | undefined,
124 | slotNameAttr: string | undefined, // slot-name attribute on slot element
125 | previousOverrideConfig: OverrideConfig[],
126 | previousDefaultContent: React.ReactNode,
127 | ): React.ReactElement {
128 | validateProps(props);
129 | // It's important that we don't remove the key that consumer provides on both
130 | // template components and slot components. In both cases the original elements are
131 | // removed from the tree but we insert (usually) a fragment there with the same key that
132 | // was specified on the element.
133 |
134 | let { config, children: _defaultContent } = extractOverrideConfig(
135 | React.isValidElement(defaultContent) &&
136 | // babel-plugin-transform-react-slots wraps children with a special element called default-content-wrapper
137 | defaultContent.type === "default-content-wrapper"
138 | ? defaultContent.props.children
139 | : defaultContent,
140 | previousOverrideConfig,
141 | slotName,
142 | );
143 |
144 | if (!this.has(slotName)) {
145 | return createSlotElement(
146 | shouldDiscard(_defaultContent)
147 | ? previousDefaultContent
148 | : _defaultContent,
149 | slotKey,
150 | slotNameAttr,
151 | );
152 | }
153 |
154 | const children = this.children.get(slotName)!;
155 |
156 | if (!children.hasTemplate) {
157 | return createSlotElement(
158 | applyOverrideToAll(
159 | children.nodes,
160 | config,
161 | 0,
162 | slotName,
163 | null,
164 | ) as React.ReactNode[],
165 | slotKey,
166 | slotNameAttr,
167 | );
168 | }
169 |
170 | return createSlotElement(
171 | React.Children.map(children.nodes, (node) => {
172 | if (isTemplateElement(node)) {
173 | let {
174 | as: Component = DEFAULT_TEMPLATE_AS,
175 | children,
176 | ...componentProps
177 | } = node.props;
178 |
179 | if ("ref" in node && node.ref !== null) {
180 | throw new Error(
181 | "Templates cannot have refs." + "as" in node.props
182 | ? " If you are trying to get a reference of the element in `as` prop, move the element inside children"
183 | : "",
184 | );
185 | }
186 |
187 | if (isTemplateComponent(Component)) {
188 | throw new Error(
189 | "Template can't accept another Template for `as` prop.",
190 | );
191 | }
192 |
193 | if (isSlotComponent(Component)) {
194 | if (typeof children === "function") {
195 | throw new Error(
196 | "Template whose `as` prop is a slot cannot have a function as a child",
197 | );
198 | }
199 |
200 | // when a slot A is being rendered by another slot B, A overrides B
201 | // in both default content and props
202 | return Component(
203 | children,
204 | {
205 | ...props,
206 | ...componentProps,
207 | },
208 | undefined,
209 | // @ts-expect-error Fourth and fifth arguments are not visible to consumers
210 | new HiddenArg(config),
211 | new HiddenArg(_defaultContent),
212 | );
213 | }
214 |
215 | const test = React.createElement(
216 | Component,
217 | componentProps,
218 | applyOverrideToAll(
219 | typeof children === "function" ? children(props) : children,
220 | config,
221 | 0,
222 | slotName,
223 | logTemplateReturnedFunctionError,
224 | ),
225 | );
226 | return test;
227 | }
228 | // If not a template element
229 | const test = applyOverride(node, config, 0, slotName);
230 | return test;
231 | }),
232 | slotKey,
233 | slotNameAttr,
234 | );
235 | }
236 |
237 | has(slotName: string): boolean {
238 | return this.children.has(slotName);
239 | }
240 |
241 | keys() {
242 | return this.children.keys();
243 | }
244 | }
245 |
246 | function logTemplateReturnedFunctionError() {
247 | console.error(
248 | "The '${slotName}' template attempted to render a function, which is not a valid React element. This will be excluded from the final result.",
249 | );
250 | }
251 |
--------------------------------------------------------------------------------
/packages/babel-plugin-transform-react-slots/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { types as t, type NodePath } from "@babel/core";
2 | import {
3 | JSXNamespacedNameError,
4 | UnsupportedMemberExpressionError,
5 | VarDeclarationError,
6 | } from "./errors";
7 |
8 | /** A wildcard for PathToValue to match any property on an object */
9 | export const ANY_PROPERTY = Symbol("AnyProperty");
10 | /**
11 | * Array of strings representation of a member expression,
12 | * Where the leftmost item is the value and everything next to it
13 | * is a parent of the previous value:
14 | * @example a.b.c -> ["b", "a"]
15 | */
16 | export type PathToValue = (string | symbol)[];
17 |
18 | function isIdentifierWithName(
19 | node: t.Node | null | undefined,
20 | name: PathToValue[number],
21 | ): node is t.Identifier {
22 | return (
23 | t.isIdentifier(node) && (name === ANY_PROPERTY ? true : node.name === name)
24 | );
25 | }
26 |
27 | export function isJSXIdentifierWithName(
28 | node: t.Node | null | undefined,
29 | name: PathToValue[number],
30 | ): node is t.JSXIdentifier {
31 | return (
32 | t.isJSXIdentifier(node) &&
33 | (name === ANY_PROPERTY ? true : node.name === name)
34 | );
35 | }
36 |
37 | function isStringLiteralWithValue(
38 | node: t.Node | null | undefined,
39 | value: PathToValue[number],
40 | ): node is t.StringLiteral {
41 | return (
42 | t.isStringLiteral(node) &&
43 | (value === ANY_PROPERTY ? true : node.value === value)
44 | );
45 | }
46 |
47 | export function skipTS(
48 | nodePath: NodePath | null,
49 | ): NodePath | null {
50 | if (nodePath === null) {
51 | return null;
52 | }
53 |
54 | if (
55 | t.isTSAsExpression(nodePath.node) ||
56 | t.isTSTypeAssertion(nodePath.node) ||
57 | t.isTSSatisfiesExpression(nodePath.node) ||
58 | t.isTSInstantiationExpression(nodePath.node)
59 | ) {
60 | return skipTS(nodePath.parentPath);
61 | }
62 |
63 | return nodePath;
64 | }
65 |
66 | /**
67 | * checks if a node is an allowed expression or statement (doesn't break our logic)
68 | */
69 | export function isUnbreaking(nodePath: NodePath): boolean {
70 | if (
71 | t.isLogicalExpression(nodePath.node) ||
72 | t.isIfStatement(nodePath.node) ||
73 | t.isExpressionStatement(nodePath.node)
74 | ) {
75 | return true;
76 | }
77 | return false;
78 | }
79 |
80 | /**
81 | * If referenced identifier is an object in a Member expression,
82 | * check if this expression is accessing the value or an ancestor of the value.
83 | * If so, return the parent node of the expression.
84 | * Empty array means the value is accessed.
85 | * If the expression is accessing a different path, null is returned.
86 | *
87 | * @throws {UnsupportedMemberExpressionError}
88 | *
89 | * @example ```ts
90 | * // z holds our value in x.y.z
91 | * getMemberExpressionParent(getX('let declaration = x.y'), ['z', 'y'])); // return: VariableDeclarator, path: ['z']
92 | * getMemberExpressionParent(getX('let declaration = x.y.z'), ['z', 'y'])); // return: VariableDeclarator, path: []
93 | * ```
94 | */
95 | export function goThroughMemberExpression(
96 | identifier: NodePath,
97 | pathToValue: PathToValue,
98 | ): NodePath | null {
99 | let parent = skipTS(identifier.parentPath);
100 | let child = skipTS(identifier);
101 |
102 | if (parent === null) {
103 | return null;
104 | }
105 |
106 | while (t.isMemberExpression(parent.node)) {
107 | const name = pathToValue.at(-1);
108 |
109 | if (name === undefined) {
110 | // Member expression is trying to access a value deeper than specified in pathToValue
111 | return null;
112 | }
113 |
114 | if (
115 | (!parent.node.computed &&
116 | isIdentifierWithName(parent.node.property, name)) ||
117 | (parent.node.computed &&
118 | isStringLiteralWithValue(parent.node.property, name))
119 | ) {
120 | pathToValue.pop();
121 | child = parent;
122 | parent = skipTS(parent.parentPath)!;
123 | } else if (
124 | (!parent.node.computed && t.isIdentifier(parent.node.property)) ||
125 | t.isStringLiteral(parent.node.property)
126 | ) {
127 | // It's still a type of property that does not break our logic, but the paths have diverged
128 | return null;
129 | } else {
130 | // computed property / Expression
131 | // eg: ReactSlots[useSlot] or ReactSlots["use" + "Slot"]
132 | throw new UnsupportedMemberExpressionError(parent.node.loc);
133 | }
134 | }
135 |
136 | return child;
137 | }
138 |
139 | /**
140 | * Get which identifiers in the object pattern hold the value (if any).
141 | * If no identifiers, return empty array.
142 | */
143 | function getValuesFromObjectPattern(
144 | objectPattern: NodePath,
145 | pathToValue: PathToValue,
146 | identifiers: Map, PathToValue>,
147 | ): void {
148 | let restHasPath = true;
149 | for (let prop of objectPattern.get("properties")) {
150 | if (t.isRestElement(prop.node)) {
151 | if (restHasPath || pathToValue.at(-1) === ANY_PROPERTY) {
152 | const identifier = prop.get("argument") as NodePath;
153 | identifiers.set(identifier, pathToValue.slice());
154 | }
155 | break;
156 | }
157 |
158 | if (
159 | pathToValue.length > 0 &&
160 | t.isObjectProperty(prop.node) &&
161 | isIdentifierWithName(prop.node.key, pathToValue.at(-1)!)
162 | ) {
163 | restHasPath = false;
164 | const name = pathToValue.pop()!;
165 |
166 | if (t.isIdentifier(prop.node.value)) {
167 | identifiers.set(
168 | prop.get("value") as NodePath,
169 | pathToValue.slice(),
170 | );
171 | pathToValue.push(name);
172 | continue;
173 | }
174 |
175 | if (t.isObjectPattern(prop.node.value)) {
176 | getValuesFromObjectPattern(
177 | prop.get("value") as NodePath,
178 | pathToValue,
179 | identifiers,
180 | );
181 | pathToValue.push(name);
182 | }
183 | }
184 | }
185 | }
186 |
187 | /**
188 | * **Mutates the pathToValue array**.
189 | *
190 | * Get values from variable declarator.
191 | * Supports direct assignment and object destructuring.
192 | * Returned map holds a new pathToValue for every specific identifier.
193 | * `pathToValue` that was passed as an argument will be mutated to be the same as `pathToValue`
194 | * for the last (rightmost) identifier
195 | *
196 | * @throws {VarDeclarationError}
197 | */
198 | export function getValuesFromVarDeclarator(
199 | varDeclarator: NodePath,
200 | pathToValue: PathToValue,
201 | ): Map, PathToValue> {
202 | const identifiers = new Map, PathToValue>();
203 |
204 | const varDeclaration = varDeclarator.parent as t.VariableDeclaration;
205 | if (!["const", "let"].includes(varDeclaration.kind)) {
206 | throw new VarDeclarationError(varDeclaration.kind, varDeclaration.loc);
207 | }
208 |
209 | if (t.isIdentifier(varDeclarator.node.id)) {
210 | identifiers.set(
211 | varDeclarator.get("id") as NodePath,
212 | pathToValue.slice(),
213 | );
214 | } else if (t.isObjectPattern(varDeclarator.node.id)) {
215 | getValuesFromObjectPattern(
216 | varDeclarator.get("id") as NodePath,
217 | pathToValue,
218 | identifiers,
219 | );
220 | }
221 |
222 | // Array expression is currently unsupported. Silently ignore
223 | return identifiers;
224 | }
225 |
226 | /**
227 | * ** Modifies pathToSlottable array **
228 | *
229 | * Validates wether the expression accesses slottable node in a jsx element.
230 | * If so, returns the parent of the expression, otherwise null;
231 | */
232 | export function getJSXElement(
233 | path: NodePath,
234 | pathToSlottable: PathToValue,
235 | ): NodePath | null {
236 | let parent = path.parentPath;
237 |
238 | while (t.isJSXMemberExpression(parent.node)) {
239 | const name = pathToSlottable.at(-1);
240 |
241 | if (name === undefined) {
242 | // JSX member expression is trying to access some value deeper than specified in pathToSlottable
243 | return null;
244 | }
245 |
246 | if (!isJSXIdentifierWithName(parent.node.property, name)) {
247 | return null;
248 | }
249 |
250 | pathToSlottable.pop();
251 | parent = parent.parentPath!;
252 | }
253 |
254 | if (pathToSlottable.length > 0) {
255 | // JSX element is ancestor of the slottable node
256 | return null;
257 | }
258 |
259 | if (t.isJSXClosingElement(parent.node)) {
260 | return null;
261 | }
262 |
263 | return parent.parentPath as NodePath;
264 | }
265 |
266 | /** @throws {JSXNamespacedNameError} */
267 | export function jsxNameToCallee(
268 | el: t.JSXIdentifier | t.JSXMemberExpression | t.JSXNamespacedName,
269 | ): t.MemberExpression | t.Identifier {
270 | if (t.isJSXIdentifier(el)) {
271 | return t.identifier(el.name);
272 | }
273 |
274 | if (t.isJSXNamespacedName(el)) {
275 | // Doesn't check JSXNamespacedName because those aren't available in react and slots can't be namespaced
276 | throw new JSXNamespacedNameError(el.loc);
277 | }
278 |
279 | return t.memberExpression(
280 | jsxNameToCallee(el.object as any),
281 | t.identifier(el.property.name),
282 | false,
283 | );
284 | }
285 |
--------------------------------------------------------------------------------
/packages/react-slots/src/types.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import {
3 | COMPONENT_TYPE,
4 | DEFAULT_TEMPLATE_AS,
5 | SLOT_NAME,
6 | TEMPLATE_TYPE_IDENTIFIER,
7 | SLOT_TYPE_IDENTIFIER,
8 | type DefaultSlotName,
9 | } from "./constants";
10 | import { Pretty, UnionToIntersection } from "./typeUtils";
11 |
12 | // Note on {} vs object in type args.
13 | // We like to write `extends object` whenever a type is exported because it's
14 | // bit more stricter to what can actually be assigned to it.
15 | // For utility types that don't get exported but are used by other types,
16 | // we prefer {} because it looks cleaner on hover
17 |
18 | type ElementType =
19 | | Exclude, SlotComponent>
20 | | Exclude>;
21 |
22 | // In edition to merging props and overriding children,
23 | // this type also manages to maintain whether children in original type
24 | // was defined as required or optional argument
25 | type MergeTPropsAndTAsProps<
26 | TProps extends {},
27 | TAsProps extends {},
28 | _TAsProps extends {} = 0 extends TAsProps & 1
29 | ? { children?: React.ReactNode }
30 | : TAsProps,
31 | > = {
32 | [K in keyof _TAsProps]: K extends "children"
33 | ? // Only allow children of the as element to be ReactNode.
34 | // This is done because slots change the children before passing it to
35 | // the initialized as element.
36 | _TAsProps[K] extends React.ReactNode
37 | ? _TAsProps[K] | ((props: TProps) => _TAsProps[K])
38 | : never
39 | : _TAsProps[K];
40 | };
41 |
42 | type TemplateProps<
43 | TProps extends {},
44 | TAs extends ElementType | SlotComponent,
45 | TAsProps extends TAs extends SlotComponent ? TProps : {},
46 | > = { as?: TAs } & (TAs extends SlotComponent
47 | ? // When we have:
48 | //
49 | // This makes sure that props that child (otherComponent) specified is optional (can be overridden)
50 | // but the extra props that's specified in parent component's slot type is required to provide
51 | {
52 | children?: React.ReactNode;
53 | } & Partial &
54 | Omit
55 | : MergeTPropsAndTAsProps);
56 |
57 | type UnwrapProps = T extends SlotComponent<
58 | infer TProps
59 | >
60 | ? TProps
61 | : React.ComponentPropsWithoutRef;
62 |
63 | export type TemplateComponent = {
64 | = typeof DEFAULT_TEMPLATE_AS>(
65 | // Typescript is failing to enforce `UnwrapProps extends TProps` so
66 | // the next best thing is to wrap the type in another conditional type
67 | // and provide a custom error.
68 | // It's probably a ts bug because TemplateProps on it's own works as expected
69 |
70 | // UnwrapProps can be any, so to avoid props inferred as string, we guard the distributivity by wrapping it in []
71 | props: [UnwrapProps] extends [
72 | TAs extends SlotComponent ? TProps : any,
73 | ]
74 | ? TemplateProps>
75 | : "Custom error: Props of `SlotComponent<:Props:>` must extend props of `TemplateComponent<:Name:, :Props:>` when used for `as` prop",
76 | ): React.ReactElement | null;
77 | [SLOT_NAME]: TName;
78 | [COMPONENT_TYPE]: typeof TEMPLATE_TYPE_IDENTIFIER;
79 | };
80 |
81 | export type TemplateComponentLikeElement<
82 | TName extends string,
83 | TProps extends {},
84 | > = React.ReactElement<
85 | {
86 | children?: React.ReactNode | ((props: TProps) => React.ReactNode);
87 | as?: React.ElementType;
88 | },
89 | {
90 | (props: any): any;
91 | [SLOT_NAME]: TName;
92 | [COMPONENT_TYPE]: typeof TEMPLATE_TYPE_IDENTIFIER;
93 | }
94 | >;
95 |
96 | export type TemplateAsSlotComponentLikeElement<
97 | TName extends string,
98 | TProps extends {},
99 | > = React.ReactElement<
100 | {
101 | children?: React.ReactNode;
102 | as?: SlotComponent;
103 | },
104 | {
105 | (props: any): any;
106 | [SLOT_NAME]: TName;
107 | [COMPONENT_TYPE]: typeof TEMPLATE_TYPE_IDENTIFIER;
108 | }
109 | >;
110 |
111 | type SlottableNode =
112 | | TemplateComponentLikeElement
113 | | TemplateAsSlotComponentLikeElement
114 | | React.ReactElement<{ "slot-name": TName }>
115 | | (DefaultSlotName extends TName ? (props: TProps) => React.ReactNode : never)
116 | | (DefaultSlotName extends TName
117 | ? string | number | boolean | null | undefined
118 | : never);
119 |
120 | // --------------- //
121 |
122 | type SlotTuple = [TName, TProps];
123 |
124 | type SlotTupleToSlottableNode> =
125 | T extends SlotTuple
126 | ? SlottableNode
127 | : never;
128 |
129 | /**
130 | * Removes the disallowed props without cluttering the type declaration.
131 | * Compared to Omit which shows up on hover
132 | */
133 | type OmitDisallowedProps = T extends
134 | | { children?: any }
135 | | { key?: any }
136 | | { as?: any }
137 | | { ref?: any }
138 | | { "slot-name"?: any }
139 | ? {
140 | [P in Exclude<
141 | keyof T,
142 | "children" | "as" | "key" | "ref" | "slot-name"
143 | >]: T[P];
144 | }
145 | : T;
146 |
147 | type DistributiveKeyOf = T extends unknown ? keyof T : never;
148 |
149 | export type Slot<
150 | TName extends string | undefined | object = undefined,
151 | TProps extends object = {},
152 | > = {
153 | value: 0 extends TName & 1
154 | ? SlotTuple
155 | : TName extends object
156 | ? SlotTuple>
157 | : SlotTuple<
158 | TName extends undefined ? DefaultSlotName : Exclude,
159 | 0 extends TProps & 1
160 | ? any // only true when TProps is any. Important for providing Base type to extend from
161 | : DistributiveKeyOf> extends never
162 | ? {} // When an empty object (or object with disallowed keys) are passed, change the type to `{}` because it looks clearer on hover
163 | : TProps extends unknown
164 | ? OmitDisallowedProps
165 | : never
166 | >;
167 | };
168 |
169 | type MergeDuplicateSlots<
170 | U extends SlotTuple,
171 | _U extends SlotTuple = U,
172 | > = U extends unknown ? SlotTuple[1]> : never;
173 |
174 | // Moving recursion outside helps display SlotChildren<...> consistently as SlottableNode<..., ...> on hover.
175 | // Sometimes this shows up in the type declaration on hover, hence why the ambiguous name
176 | // FIXME: Something used to break if Iterable was used instead of an array but can't remember what, although
177 | // using iterable makes it so that ReactNode is assignable to SlotChildren
178 | type Children> = T | Iterable>;
179 |
180 | type UnwrapValue> = T["value"];
181 |
182 | type AnyCase = 0 extends TSlot & 1 ? Slot : TSlot;
183 |
184 | /**
185 | * Declares the type of children based on slots.
186 | * Provide the type union of `Slot` to accept different types of slotted children.
187 | * @example
188 | * ```ts
189 | * // The following type accepts children with three types of slots
190 | * type Children = Slots | Slot<"bar", {baz: boolean}>>
191 | * ```
192 | */
193 | export type SlotChildren = Slot> =
194 | | SlotTupleToSlottableNode>>>
195 | | Children<
196 | SlotTupleToSlottableNode>>>
197 | >;
198 |
199 | /**
200 | * @example
201 | * ```ts
202 | * type X = GetTemplateUnions | Slot<"bar", {baz: string}>>>
203 | * // | {default: TemplateComponent<"default", {}>
204 | * // | {foo: TemplateComponent<'foo', {}>
205 | * // | {bar: TemplateComponent<'bar', {baz: string}>}
206 | * ```
207 | */
208 | type GetTemplateUnions = U extends TemplateAsSlotComponentLikeElement<
209 | infer N,
210 | infer P
211 | >
212 | ? { [Name in Exclude]: TemplateComponent }
213 | : never;
214 | /**
215 | * Create type-safe template
216 | */
217 | export type CreateTemplate = Pretty<
218 | UnionToIntersection<
219 | GetTemplateUnions>
220 | >
221 | >;
222 |
223 | // ------------------ //
224 |
225 | export type SlotProps = TProps & {
226 | children?: React.ReactNode;
227 | };
228 |
229 | // If all properties on P are optional then defaultContent and props can be optional,
230 | // Otherwise both should be specified
231 | export type SlotComponent = {
232 | [COMPONENT_TYPE]: typeof SLOT_TYPE_IDENTIFIER;
233 | } & ([Partial
] extends [P]
234 | ? {
235 | /** Slot Component */
236 | (props: SlotProps
): React.ReactElement | null;
237 | /** Slot Function */
238 | (
239 | defaultContent?: React.ReactNode,
240 | props?: P & { key?: React.Key },
241 | key?: React.Key,
242 | ): React.ReactElement | null;
243 | }
244 | : {
245 | /** Slot Component */
246 | (props: SlotProps
): React.ReactElement | null;
247 | /** Slot Function */
248 | (
249 | defaultContent: React.ReactNode,
250 | props: P & { key?: React.Key },
251 | key?: React.Key,
252 | ): React.ReactElement | null;
253 | });
254 |
255 | type GetSlotComponentUnions = T extends TemplateAsSlotComponentLikeElement<
256 | infer N,
257 | infer P
258 | >
259 | ? { [Name in N]: SlotComponent }
260 | : never;
261 |
262 | type CreateSlotComponent = {} & Pretty<
263 | UnionToIntersection<
264 | GetSlotComponentUnions<0 extends T & 1 ? SlotChildren : T>
265 | >
266 | >;
267 |
268 | export type CreateSlot = CreateSlotComponent;
269 |
270 | // -------------------- //
271 |
272 | export type HasSlot = Pretty<{
273 | [Name in Extract<
274 | T,
275 | React.ReactElement<{ "slot-name": string }>
276 | >["props"]["slot-name"]]: true | undefined;
277 | }>;
278 |
279 | // -------------------- //
280 |
--------------------------------------------------------------------------------
/packages/react-slots/src/OverrideNode.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { forEachNode, forEachNodeReplace } from "./forEachNode";
3 | import { NoInfer, Pretty } from "./typeUtils";
4 |
5 | type AllowedNodes = React.ElementType | StringConstructor | NumberConstructor;
6 |
7 | type GetProps = T extends StringConstructor
8 | ? never
9 | : T extends NumberConstructor
10 | ? never
11 | : React.ComponentPropsWithRef;
12 |
13 | type GetNode = T extends NumberConstructor
14 | ? number
15 | : T extends StringConstructor
16 | ? string
17 | : React.ReactElement<
18 | GetProps,
19 | Exclude
20 | >;
21 |
22 | // In an ideal world, OverrideNodeProps would be an union of two objects where one has 'node'
23 | // and the other one has 'props` but TS doesn't enforce that and
24 | // using Union.Strict gives a really cryptic error.
25 | type OverrideNodeProps<
26 | T extends AllowedNodes,
27 | U extends "throw" | "ignore" | "remove" = "throw",
28 | TNode = GetNode,
29 | TProps = Pretty>,
30 | > = {
31 | /**
32 | * An array specifying allowed React intrinsic element names or custom component names.
33 | * It restricts the provided element types or direct children types (if no content is provided).
34 | * Include capital String or capital Number to denote string and number nodes.
35 | * Combine with `enforce` to handle disallowed nodes and with `props` or `node` to transform matching nodes.
36 | */
37 | allowedNodes?: readonly T[];
38 | /**
39 | * Specifies the action to take with nodes that aren't allowed, in combination with `allowedNodes`.
40 | * By default an error is thrown when it comes across a disallowed node.
41 | */
42 | enforce?: U;
43 | children?: U extends "ignore" ? React.ReactNode : TNode;
44 | /**
45 | * Transforms the props of provided elements or fallback elements. You can provide a function that will be
46 | * called with the current props of the element, and the returned object will be merged into the existing props.
47 | * Alternatively, you can use an object with prop names and transformation functions.
48 | * Return value of transformation functions overrides the prop. There are some predefined Transformation
49 | * functions on OverrideNode object which you can use.
50 | */
51 | props?:
52 | | ((props: TProps) => Partial)
53 | | {
54 | [K in keyof TProps]?: (
55 | prop: Exclude,
56 | propName: K,
57 | slotName: string,
58 | ) => TProps[K];
59 | };
60 | /**
61 | * Called with each provided node and can be used to transform or replace each node.
62 | */
63 | node?: (node: TNode) => React.ReactNode;
64 | };
65 |
66 | export type OverrideConfig = React.ReactElement<
67 | OverrideNodeProps
68 | >;
69 |
70 | export type OverrideNode = {
71 | (
72 | props: OverrideNodeProps,
73 | ): React.ReactElement | null;
74 | /** Concatenates a string x with a space to the original prop. */
75 | stringAppend: (
76 | x: string,
77 | ) => (
78 | prop: T,
79 | ) =>
80 | | string
81 | | (undefined extends T ? undefined : never)
82 | | (null extends T ? null : never);
83 | /** prepends a string x with a space to the original prop. */
84 | stringPrepend: (
85 | x: string,
86 | ) => (
87 | prop: T,
88 | ) =>
89 | | string
90 | | (undefined extends T ? undefined : never)
91 | | (null extends T ? null : never);
92 | /** Intercepts a call to a prop and executes the x function before the original function. */
93 | chainBefore: any>(
94 | x: NoInfer,
95 | ) => (prop: T, propName: keyof any, slotName: string) => T;
96 | /** Intercepts a call to a prop and executes the x function after the original function. */
97 | chainAfter: any>(
98 | x: NoInfer,
99 | ) => (prop: T, propName: keyof any, slotName: string) => T;
100 | /** Overrides prop */
101 | override: (x: NoInfer) => (prop: T) => T;
102 | };
103 |
104 | export const OverrideNode = (() => {
105 | throw new Error(
106 | `\` \` can only be a direct child of a slot element. \
107 | Make sure \`OverrideNode\` is not wrapped in an element other than \`slot\``,
108 | );
109 | }) as unknown as OverrideNode;
110 |
111 | function stringConcat(a: unknown, b: unknown): string {
112 | let newA = a == undefined ? "" : "" + a;
113 | let newB = b == undefined ? "" : "" + b;
114 |
115 | return newA && newB ? `${newA} ${newB}` : `${newA}${newB}`;
116 | }
117 |
118 | function executeOrThrow(
119 | prop: ((...args: any[]) => any) | undefined,
120 | args: any[],
121 | propName: keyof any,
122 | slotName: string,
123 | ) {
124 | if (typeof prop === "function") {
125 | return prop(...args);
126 | } else if (prop == undefined) {
127 | return undefined;
128 | }
129 | throw new Error(
130 | `Expected the ${propName.toString()} prop for an element for ${slotName} slot to be a \`function\` or \`undefined\`, instead saw ${
131 | prop === null ? null : typeof prop
132 | } `,
133 | );
134 | }
135 |
136 | OverrideNode.stringAppend =
137 | (x) =>
138 | (prop): any => {
139 | if (x === undefined || x === null) {
140 | return prop;
141 | }
142 | return stringConcat(prop, x);
143 | };
144 | OverrideNode.stringPrepend =
145 | (x) =>
146 | (prop): any => {
147 | if (x === undefined || x === null) {
148 | return prop;
149 | }
150 | return stringConcat(x, prop);
151 | };
152 | OverrideNode.chainBefore =
153 | (x) =>
154 | (prop, propName, slotName): any => {
155 | return function (...args: Parameters) {
156 | x(...args);
157 | return executeOrThrow(prop, args, propName, slotName);
158 | };
159 | };
160 | OverrideNode.chainAfter =
161 | (x) =>
162 | (prop, propName, slotName): any => {
163 | return function (...args: Parameters) {
164 | const returnVal = executeOrThrow(prop, args, propName, slotName);
165 | x(...args);
166 | return returnVal;
167 | };
168 | };
169 | OverrideNode.override = (x) => () => {
170 | return x;
171 | };
172 |
173 | export function extractOverrideConfig(
174 | nodes: React.ReactNode,
175 | appendConfig: OverrideConfig[],
176 | slotName: string,
177 | ): {
178 | children: React.ReactNode;
179 | config: OverrideConfig[];
180 | } {
181 | const config: OverrideConfig[] = [];
182 |
183 | const children = React.Children.map(nodes, (node) => {
184 | if (React.isValidElement(node) && node.type === OverrideNode) {
185 | node = node as OverrideConfig;
186 | config.push(node);
187 |
188 | if (node.props.children) {
189 | return (
190 |
194 | {node.props.children}
195 |
196 | );
197 | } else {
198 | // Replace OverrideNode that has no children
199 | // with nulls to maintain the shape of children.
200 | return null;
201 | }
202 | } else {
203 | if (appendConfig.length) {
204 | return (
205 |
206 | {node}
207 |
208 | );
209 | }
210 | return node;
211 | }
212 | });
213 |
214 | return { config: config.concat(appendConfig), children };
215 | }
216 |
217 | function OverrideChildren(props: {
218 | children: React.ReactNode;
219 | config: OverrideConfig[];
220 | slotName: string;
221 | }) {
222 | return applyOverrideToAll(
223 | props.children,
224 | props.config,
225 | 0,
226 | props.slotName,
227 | () => {
228 | console.error(
229 | `The '${props.slotName}' slot attempted to render a function as fallback content, which is not a valid React element. It will be excluded from the final result.`,
230 | );
231 | },
232 | );
233 | }
234 |
235 | function isAllowed(
236 | type: any,
237 | allowedNodes: readonly AllowedNodes[] | undefined,
238 | ) {
239 | if (!Array.isArray(allowedNodes)) {
240 | return true;
241 | }
242 | return allowedNodes.includes(type);
243 | }
244 |
245 | function enforce(
246 | enforceType: "throw" | "ignore" | "remove" | undefined,
247 | child: T,
248 | allowedNodes: readonly AllowedNodes[],
249 | slotName: string,
250 | ) {
251 | switch (enforceType) {
252 | case "ignore":
253 | return child;
254 | case "remove":
255 | return null;
256 | case "throw":
257 | default: {
258 | const error = `${
259 | React.isValidElement(child)
260 | ? `${
261 | typeof child.type === "function"
262 | ? (child.type as any).displayName || child.type.name
263 | : child.type
264 | } element`
265 | : typeof child
266 | } is not a valid node type for the '${slotName}' slot. Allowed nodes are: ${allowedNodes.map(
267 | (n) =>
268 | n === String
269 | ? "string literal"
270 | : n === Number
271 | ? "number literal"
272 | : typeof n === "function"
273 | ? (n as any).displayName || n.name
274 | : n,
275 | )}`;
276 | if (process.env.NODE_ENV === "production") {
277 | console.error(error);
278 | return null;
279 | }
280 | throw new Error(error);
281 | }
282 | }
283 | }
284 |
285 | export function applyOverrideToAll(
286 | children: React.ReactNode,
287 | config: OverrideConfig[],
288 | configStartIndex: number,
289 | slotName: string,
290 | logFunctionError: ((slotName: string) => void) | null,
291 | ) {
292 | if (logFunctionError) {
293 | forEachNode(children, (child) => {
294 | if (typeof child === "function") {
295 | logFunctionError(slotName);
296 | }
297 | });
298 | }
299 |
300 | return React.Children.map(children, (child) => {
301 | return applyOverride(
302 | child as Exclude>,
303 | config,
304 | configStartIndex,
305 | slotName,
306 | );
307 | });
308 | }
309 |
310 | export function applyOverride(
311 | child: Exclude>,
312 | config: OverrideConfig[],
313 | configStartIndex: number,
314 | slotName: string,
315 | ): React.ReactNode {
316 | let newChild = child;
317 |
318 | for (let i = configStartIndex; i < config.length; i++) {
319 | switch (newChild) {
320 | case undefined:
321 | case null:
322 | case true:
323 | case false:
324 | return null;
325 | }
326 |
327 | let currentConfig = config[i].props;
328 |
329 | if ("props" in currentConfig && "node" in currentConfig) {
330 | console.error(
331 | `\`OverrideNode\` cannot have both \`props\` and \`node\` as props, only the \`node\` function will be executed. Found This error on \`OverrideNode\` of '${slotName}' slot`,
332 | );
333 | }
334 |
335 | if (
336 | !isAllowed(
337 | typeof newChild === "string"
338 | ? String
339 | : typeof newChild === "number"
340 | ? Number
341 | : React.isValidElement(newChild)
342 | ? newChild.type
343 | : newChild,
344 | currentConfig.allowedNodes,
345 | )
346 | ) {
347 | newChild = enforce(
348 | currentConfig.enforce,
349 | newChild,
350 | currentConfig.allowedNodes!,
351 | slotName,
352 | );
353 | continue;
354 | }
355 |
356 | if (typeof currentConfig.node === "function") {
357 | const overriddenNodes = currentConfig.node(newChild);
358 | // Consumer can override node's type and even return multiple nodes,
359 | // so we need to start applying the remaining override config to each of the returned nodes.
360 | return forEachNodeReplace(overriddenNodes, (node) => {
361 | if (typeof node === "function") {
362 | console.error(
363 | `A function was returned to replace a React node in the \`node\` property of the '${slotName}' slot's \`OverrideNode\`. Functions are not valid React elements and will be removed from the final result.`,
364 | );
365 | return null;
366 | }
367 | return applyOverride(node, config, i + 1, slotName);
368 | });
369 | }
370 |
371 | if (React.isValidElement(newChild)) {
372 | if (typeof currentConfig.props === "function") {
373 | newChild = React.cloneElement(
374 | newChild,
375 | currentConfig.props(newChild.props as any),
376 | );
377 | } else if (
378 | typeof currentConfig.props === "object" &&
379 | typeof currentConfig.props !== null
380 | ) {
381 | const newProps: typeof currentConfig.props = {};
382 | for (const [prop, override] of Object.entries(currentConfig.props)) {
383 | if (typeof override === "function") {
384 | newProps[prop] = override(
385 | (newChild.props as any)[prop],
386 | prop,
387 | slotName,
388 | );
389 | }
390 | }
391 | newChild = React.cloneElement(newChild, newProps);
392 | }
393 | }
394 | }
395 |
396 | return newChild;
397 | }
398 |
--------------------------------------------------------------------------------
/packages/babel-plugin-transform-react-slots/src/index.ts:
--------------------------------------------------------------------------------
1 | import { types as t, type NodePath } from "@babel/core";
2 | import { declare } from "@babel/helper-plugin-utils";
3 | import {
4 | type PathToValue,
5 | goThroughMemberExpression,
6 | getValuesFromVarDeclarator,
7 | getJSXElement,
8 | ANY_PROPERTY,
9 | jsxNameToCallee,
10 | skipTS,
11 | isUnbreaking,
12 | } from "./utils";
13 | import syntaxJSX from "@babel/plugin-syntax-jsx";
14 | import rawCodeStore from "./rawCodeStore";
15 | import {
16 | UnsupportedMemberExpressionError,
17 | UnsupportedSyntaxError,
18 | VarDeclarationError,
19 | } from "./errors";
20 |
21 | // TODO: move constants to a shared package
22 | const LIB_SOURCE = "@beqa/react-slots";
23 | const IMPORTED_NODE = "useSlot";
24 | const SLOT_OBJECT_NAME = "slot";
25 | const DISABLED_REGEX = /^\s*@disable-transform-react-slots\W/;
26 | const DEFAULT_CONTENT_WRAPPER = "default-content-wrapper";
27 |
28 | function isDisabled(
29 | comments: (t.CommentLine | t.CommentBlock)[] | null | undefined,
30 | program: t.Program,
31 | ) {
32 | if (!comments) {
33 | return false;
34 | }
35 |
36 | const programStart = program.directives.length
37 | ? program.directives[0]?.start || 0
38 | : program.body.length
39 | ? program.body[0]?.start || 0
40 | : 0;
41 |
42 | let currentIndex = 0;
43 | while (
44 | comments[currentIndex] &&
45 | comments[currentIndex]!.start !== undefined &&
46 | comments[currentIndex]!.start! <= programStart
47 | ) {
48 | if (
49 | comments[currentIndex]?.type === "CommentLine" &&
50 | DISABLED_REGEX.test(comments[currentIndex]!.value)
51 | ) {
52 | return true;
53 | }
54 | ++currentIndex;
55 | }
56 |
57 | if (comments[currentIndex] && comments[currentIndex]?.start === undefined) {
58 | throw new Error(
59 | "Could not read comments for @beqa/react-slots while looking for a `@disable-transform-react-slots` pragma. Please open a new issue on our Github repo.",
60 | );
61 | }
62 |
63 | return false;
64 | }
65 |
66 | /** Find all useSlot imports (It's possible for the lib to be imported multiple times) */
67 | function findImports(
68 | path: NodePath,
69 | ): [t.ImportSpecifier[], t.ImportNamespaceSpecifier[]] {
70 | const importNodes = path.node.body.filter((node) => {
71 | return (
72 | t.isImportDeclaration(node) &&
73 | node.source.value === LIB_SOURCE &&
74 | node.importKind !== "type" &&
75 | node.importKind !== "typeof"
76 | );
77 | }) as t.ImportDeclaration[];
78 |
79 | let imports = [];
80 | let namespaceImports = [];
81 |
82 | for (let node of importNodes) {
83 | for (let specifier of node.specifiers) {
84 | if (t.isImportNamespaceSpecifier(specifier)) {
85 | namespaceImports.push(specifier);
86 | } else if (
87 | t.isImportSpecifier(specifier) &&
88 | (specifier.imported as any).name === IMPORTED_NODE &&
89 | specifier.importKind !== "typeof" &&
90 | specifier.importKind !== "type"
91 | ) {
92 | imports.push(specifier);
93 | }
94 | }
95 | }
96 |
97 | return [imports, namespaceImports];
98 | }
99 |
100 | /**
101 | * Find all useSlot or alias call expressions.
102 | * Will mutate callExpressions array to include new references
103 | **/
104 | function findCallExpressions(
105 | referencePaths: NodePath[],
106 | pathToUseSlot: PathToValue = [],
107 | callExpressions: NodePath[],
108 | ) {
109 | for (const ref of referencePaths) {
110 | const path = pathToUseSlot.slice();
111 |
112 | let nodePath;
113 |
114 | try {
115 | nodePath = goThroughMemberExpression(ref, path);
116 | } catch (err) {
117 | if (err instanceof UnsupportedMemberExpressionError) {
118 | err.throw(
119 | `Unsupported syntax: Member expression accessing a \`useSlot\` value can only use dot notation or bracket notation (iff the value is a string literal). Allowed syntax is: \`ReactSlots.useSlot\` or \`ReactSlots["useSlot"]\`.`,
120 | );
121 | }
122 | throw err;
123 | }
124 |
125 | if (nodePath === null) {
126 | continue;
127 | }
128 |
129 | let parentPath = skipTS(nodePath.parentPath)!;
130 |
131 | if (isUnbreaking(parentPath)) {
132 | continue;
133 | }
134 |
135 | // TODO: also allow object variable declarations `let x = {a: {b: useSlot}}`
136 |
137 | if (
138 | t.isCallExpression(parentPath.node) &&
139 | skipTS(parentPath.get("callee") as NodePath)?.node ===
140 | nodePath.node
141 | ) {
142 | if (path.length !== 0) {
143 | // Call expression on an ancestor of useSlot
144 | new UnsupportedSyntaxError(parentPath.node.loc).throw(
145 | "Unsupported syntax: Object that holds the nested `useSlot` value was used as a function. Did you mean to do " +
146 | "`" +
147 | rawCodeStore
148 | .get()
149 | .slice(
150 | parentPath.node.callee.start || 0,
151 | parentPath.node.callee.end || 0,
152 | ) +
153 | "." +
154 | path.reverse().join(".") +
155 | "()" +
156 | "`?",
157 | );
158 | }
159 |
160 | callExpressions.push(parentPath as NodePath);
161 | } else if (t.isVariableDeclarator(parentPath.node)) {
162 | try {
163 | const newIdentifiers = getValuesFromVarDeclarator(
164 | parentPath as NodePath,
165 | path,
166 | );
167 |
168 | if (newIdentifiers.size) {
169 | for (const [newIdentifier, newPath] of newIdentifiers) {
170 | findCallExpressions(
171 | newIdentifier.scope.bindings[newIdentifier.node.name]!
172 | .referencePaths,
173 | newPath,
174 | callExpressions,
175 | );
176 | }
177 | }
178 | } catch (err) {
179 | if (err instanceof VarDeclarationError) {
180 | err.throw(
181 | "Unsupported syntax: You must only use `let` or `const` variable declarations with `useSlot`, instead encountered " +
182 | err.kind +
183 | ".",
184 | );
185 | }
186 | throw err;
187 | }
188 | } else {
189 | // error
190 | new UnsupportedSyntaxError(parentPath.node.loc).throw(
191 | "Unsupported syntax: `useSlot` or an object holding a nested `useSlot` value used inside " +
192 | parentPath.node.type +
193 | ".",
194 | );
195 | }
196 | }
197 | }
198 |
199 | function findJSXElements(
200 | referencePaths: NodePath[],
201 | pathToSlottable: PathToValue, // [ANY_Property, "slot"]
202 | jsxElements: NodePath[],
203 | ) {
204 | for (let ref of referencePaths) {
205 | const path = pathToSlottable.slice();
206 |
207 | if (t.isJSXIdentifier(ref.node)) {
208 | const jsxElement = getJSXElement(ref as NodePath, path);
209 | if (jsxElement) {
210 | jsxElements.push(jsxElement);
211 | }
212 | } else {
213 | let nodePath;
214 | try {
215 | nodePath = goThroughMemberExpression(ref, path);
216 | } catch (err) {
217 | if (err instanceof UnsupportedMemberExpressionError) {
218 | err.throw(
219 | 'Unsupported syntax: Member expression accessing a slottable node can only use dot notation or bracket notation (iff the value is a string literal). eg: `useSlot().slot.default` or `useSlot()["slot"].default`.',
220 | );
221 | }
222 | }
223 |
224 | if (nodePath === null || nodePath === undefined) {
225 | continue;
226 | }
227 |
228 | let parentPath = skipTS(nodePath.parentPath)!;
229 |
230 | if (
231 | t.isCallExpression(parentPath.node) &&
232 | path.length === 0 &&
233 | skipTS(parentPath.get("callee") as NodePath)?.node ===
234 | nodePath.node
235 | ) {
236 | // Using the call signature of slot element
237 | continue;
238 | }
239 |
240 | if (isUnbreaking(parentPath)) {
241 | // eg: ReactSlots.useSlot(); or slot.default; (no assignment) hurts no one
242 | continue;
243 | }
244 |
245 | if (t.isVariableDeclarator(parentPath.node)) {
246 | try {
247 | const newIdentifiers = getValuesFromVarDeclarator(
248 | parentPath as NodePath,
249 | path,
250 | );
251 | if (newIdentifiers.size) {
252 | for (const [newIdentifier, newPath] of newIdentifiers) {
253 | const { scope, node } = newIdentifier;
254 | findJSXElements(
255 | scope.bindings[node.name]!.referencePaths,
256 | newPath,
257 | jsxElements,
258 | );
259 | }
260 | }
261 | } catch (err) {
262 | if (err instanceof VarDeclarationError) {
263 | err.throw(
264 | "Unsupported syntax: You must only use `let` or `const` variable declarations for slottable elements, instead encountered " +
265 | err.kind,
266 | );
267 | }
268 | throw err;
269 | }
270 | } else {
271 | new UnsupportedSyntaxError(parentPath.node.loc).throw(
272 | "Unsupported syntax: A slottable element or an object holding a nested slottable element used inside " +
273 | parentPath.node.type,
274 | );
275 | }
276 | }
277 | }
278 | }
279 |
280 | function transformJSXElements(element: NodePath) {
281 | const defaultContent = element.node.children;
282 | const props = element.node.openingElement.attributes.map((attr) => {
283 | if (t.isJSXSpreadAttribute(attr)) {
284 | return t.spreadElement(attr.argument);
285 | }
286 |
287 | return t.objectProperty(
288 | t.isJSXNamespacedName(attr.name)
289 | ? t.identifier(`${attr.name.namespace}:${attr.name.name}`)
290 | : t.identifier(attr.name.name),
291 | // I don't think attr.value can ever be JSXEmptyExpression or
292 | // attr.value.expression can be undefined
293 | // but it's there in ts declarations so we are handling it.
294 | t.isJSXExpressionContainer(attr.value)
295 | ? t.isJSXEmptyExpression(attr.value.expression)
296 | ? t.identifier("undefined")
297 | : attr.value.expression
298 | : attr.value === null || attr.value === undefined
299 | ? t.booleanLiteral(true)
300 | : attr.value,
301 | );
302 | });
303 |
304 | const callExpression = t.callExpression(
305 | jsxNameToCallee(element.node.openingElement.name),
306 | [
307 | defaultContent.length
308 | ? t.jsxElement(
309 | t.jsxOpeningElement(t.jsxIdentifier(DEFAULT_CONTENT_WRAPPER), []),
310 | t.jsxClosingElement(t.jsxIdentifier(DEFAULT_CONTENT_WRAPPER)),
311 | defaultContent,
312 | )
313 | : t.jsxElement(
314 | t.jsxOpeningElement(
315 | t.jsxIdentifier(DEFAULT_CONTENT_WRAPPER),
316 | [],
317 | true,
318 | ),
319 | null,
320 | [],
321 | true,
322 | ),
323 | (props.length && t.objectExpression(props)) as t.ObjectExpression,
324 | ].filter(Boolean),
325 | );
326 |
327 | element.replaceWith(
328 | t.isJSXElement(element.parent) || t.isJSXFragment(element.parent)
329 | ? t.inherits(t.jSXExpressionContainer(callExpression), element.node)
330 | : t.inherits(callExpression, element.node),
331 | );
332 | }
333 |
334 | export = declare((api) => {
335 | api.assertVersion(7);
336 |
337 | const jsxElementSet = new Set();
338 |
339 | return {
340 | name: "transform-react-slots",
341 | inherits: syntaxJSX,
342 | pre(file) {
343 | rawCodeStore.set(file.code);
344 | jsxElementSet.clear();
345 | },
346 | post() {
347 | rawCodeStore.set("");
348 | },
349 | visitor: {
350 | Program: {
351 | enter(path, state) {
352 | if (isDisabled(state.file.ast.comments, path.node)) {
353 | return;
354 | }
355 |
356 | const [imports, namespaceImports] = findImports(path);
357 |
358 | // Exit early if no useSlot imports
359 | if (!imports.length && !namespaceImports.length) {
360 | return;
361 | }
362 |
363 | const callExpressions: NodePath[] = [];
364 |
365 | for (const importNode of imports) {
366 | findCallExpressions(
367 | path.scope.bindings[importNode.local.name]!.referencePaths,
368 | [],
369 | callExpressions,
370 | );
371 | }
372 | for (const importNode of namespaceImports) {
373 | findCallExpressions(
374 | path.scope.bindings[importNode.local.name]!.referencePaths,
375 | [IMPORTED_NODE],
376 | callExpressions,
377 | );
378 | }
379 | const jsxElements: NodePath[] = [];
380 |
381 | findJSXElements(
382 | callExpressions,
383 | [ANY_PROPERTY, SLOT_OBJECT_NAME],
384 | jsxElements,
385 | );
386 |
387 | jsxElements.forEach((path) => {
388 | jsxElementSet.add(path.node);
389 | });
390 | },
391 | },
392 | JSXElement: {
393 | enter(path) {
394 | if (jsxElementSet.has(path.node)) {
395 | transformJSXElements(path);
396 | }
397 | },
398 | },
399 | },
400 | };
401 | });
402 |
--------------------------------------------------------------------------------