From 36f462b1ef858bfce95ae796fed9c6ea1f3cc1cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Sat, 23 Dec 2023 16:22:44 +0400 Subject: [PATCH 1/2] feat: add additional props support --- .eslintrc.json | 5 +- .npmignore | 3 +- README.md | 2 +- rollup.config.js | 36 +++++-- src/index.test.tsx | 47 +++++++++- src/index.tsx | 93 +++++++++++++------ .../pages/docs/guides/additional-props.mdx | 34 +++++++ .../docs/guides/styling-any-component.mdx | 8 ++ website/pages/index.mdx | 2 +- 9 files changed, 185 insertions(+), 45 deletions(-) create mode 100644 website/pages/docs/guides/additional-props.mdx diff --git a/.eslintrc.json b/.eslintrc.json index 61c0783..c92d7f5 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -5,5 +5,8 @@ }, "parser": "@typescript-eslint/parser", "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], - "plugins": ["@typescript-eslint"] + "plugins": ["@typescript-eslint"], + "rules": { + "@typescript-eslint/ban-types": "off" + } } diff --git a/.npmignore b/.npmignore index 3797ac9..c183ce1 100644 --- a/.npmignore +++ b/.npmignore @@ -1,2 +1,3 @@ /* -!/dist/**/*.{ts,js} \ No newline at end of file +!/dist/index.d.ts +!/dist/index.mjs \ No newline at end of file diff --git a/README.md b/README.md index 1793b77..35d116d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ ## Features -- ⚡️ Lightweight — only 0.42kb +- ⚡️ Lightweight — only 0.44kb - ✨ Autocompletion in all editors - 🎨 Adapt the style based on props - ♻️ Reuse classes with `asChild` prop diff --git a/rollup.config.js b/rollup.config.js index 9609e74..25abcb5 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,15 +1,17 @@ import { swc } from "rollup-plugin-swc3"; import ts from "rollup-plugin-ts"; -const swcPlugin = swc({ - tsconfig: false, - jsc: { - parser: { - syntax: "typescript", +const swcPlugin = (minify) => + swc({ + tsconfig: false, + minify, + jsc: { + parser: { + syntax: "typescript", + }, + target: "es2018", }, - target: "es2018", - }, -}); + }); const tsPlugin = ts({ transpiler: "swc" }); @@ -26,7 +28,21 @@ const buildEs = ({ file: output, format: "es", }, - plugins: [swcPlugin], + plugins: [swcPlugin(false)], +}); + +const buildMin = ({ + input = "src/index.tsx", + output = "dist/index.min.mjs", + external = ignoreRelative, +} = {}) => ({ + input, + external, + output: { + file: output, + format: "es", + }, + plugins: [swcPlugin(true)], }); const buildTypes = ({ @@ -43,4 +59,4 @@ const buildTypes = ({ plugins: [tsPlugin], }); -export default [buildEs(), buildTypes()]; +export default [buildEs(), buildMin(), buildTypes()]; diff --git a/src/index.test.tsx b/src/index.test.tsx index c08139c..d21ca05 100644 --- a/src/index.test.tsx +++ b/src/index.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, test, beforeEach } from "vitest"; import { render, screen, cleanup } from "@testing-library/react"; import { cva, VariantProps } from "class-variance-authority"; -import { twc, createTwc } from "./index"; +import { twc, createTwc, TwcComponentProps } from "./index"; import * as React from "react"; import { twMerge } from "tailwind-merge"; @@ -25,6 +25,51 @@ describe("twc", () => { expect(title.dataset.foo).toBe("bar"); }); + test("supports attrs", () => { + const Checkbox = twc.input.attrs({ type: "checkbox" })`text-xl`; + render(); + const checkbox = screen.getByTestId("checkbox"); + expect(checkbox).toBeDefined(); + expect(checkbox.getAttribute("type")).toBe("checkbox"); + }); + + test("supports attrs from props", () => { + const Checkbox = twc.input.attrs<{ $type?: string }>((props) => ({ + type: props.$type || "checkbox", + "data-testid": "checkbox", + }))`text-xl`; + render(); + const checkbox = screen.getByTestId("checkbox"); + expect(checkbox).toBeDefined(); + expect(checkbox.getAttribute("type")).toBe("checkbox"); + + cleanup(); + + render(); + const radio = screen.getByTestId("checkbox"); + expect(radio).toBeDefined(); + expect(radio.getAttribute("type")).toBe("radio"); + }); + + test("complex attrs support", () => { + type LinkProps = TwcComponentProps<"a"> & { $external?: boolean }; + + // Accept an $external prop that adds `target` and `rel` attributes if present + const Link = twc.a.attrs((props) => + props.$external ? { target: "_blank", rel: "noopener noreferrer" } : {}, + )`appearance-none size-4 border-2 border-blue-500 rounded-sm bg-white`; + + render( + + My link + , + ); + const link = screen.getByText("My link"); + expect(link).toBeDefined(); + expect(link.getAttribute("target")).toBe("_blank"); + expect(link.getAttribute("rel")).toBe("noopener noreferrer"); + }); + test("merges classes", () => { const Title = twc.h1`text-xl`; render(Title); diff --git a/src/index.tsx b/src/index.tsx index 2e331cc..7d7c5dd 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -26,7 +26,8 @@ type Template< TComponent extends React.ElementType, TCompose extends AbstractCompose, TExtraProps, -> = ( + TParentProps = undefined, +> = ( strings: | TemplateStringsArray | (( @@ -37,10 +38,24 @@ type Template< ResultProps >; +type FirstLevelTemplate< + TComponent extends React.ElementType, + TCompose extends AbstractCompose, + TExtraProps, +> = Template & { + attrs: ( + attrs: + | Record + | (( + props: ResultProps, + ) => Record), + ) => Template; +}; + type Twc = (( component: T, -) => Template) & { - [Key in keyof HTMLElementTagNameMap]: Template< +) => FirstLevelTemplate) & { + [Key in keyof HTMLElementTagNameMap]: FirstLevelTemplate< Key, TCompose, { asChild?: boolean } @@ -81,44 +96,62 @@ function filterProps( return filteredProps; } +type Attributes = Record | ((props: any) => Record); + export const createTwc = ( config: Config = {}, ) => { - const compose = config.compose ?? clsx; + const compose = config.compose || clsx; const shouldForwardProp = - config.shouldForwardProp ?? ((prop) => prop[0] !== "$"); - const template = - (Component: React.ElementType) => - // eslint-disable-next-line @typescript-eslint/ban-types - (stringsOrFn: TemplateStringsArray | Function, ...values: any[]) => { - const isFn = typeof stringsOrFn === "function"; - const twClassName = isFn - ? "" - : String.raw({ raw: stringsOrFn }, ...values); - return React.forwardRef((props: any, ref) => { - const { className, asChild, ...rest } = props; - const filteredProps = filterProps(rest, shouldForwardProp); - const Comp = asChild ? Slot : Component; - return ( - - ); - }); + config.shouldForwardProp || ((prop) => prop[0] !== "$"); + const wrap = (Component: React.ElementType) => { + const createTemplate = (attrs?: Attributes) => { + const template = ( + stringsOrFn: TemplateStringsArray | Function, + ...values: any[] + ) => { + const isFn = typeof stringsOrFn === "function"; + const twClassName = isFn + ? "" + : String.raw({ raw: stringsOrFn }, ...values); + return React.forwardRef((p: any, ref) => { + const { className, asChild, ...rest } = p; + const rp = + typeof attrs === "function" ? attrs(p) : attrs ? attrs : {}; + const fp = filterProps({ ...rp, ...rest }, shouldForwardProp); + const Comp = asChild ? Slot : Component; + return ( + + ); + }); + }; + + if (attrs === undefined) { + template.attrs = (attrs: Attributes) => { + return createTemplate(attrs); + }; + } + + return template; }; + return createTemplate(); + }; + return new Proxy( (component: React.ComponentType) => { - return template(component); + return wrap(component); }, { get(_, name) { - return template(name as keyof JSX.IntrinsicElements); + return wrap(name as keyof JSX.IntrinsicElements); }, }, ) as any as Twc; diff --git a/website/pages/docs/guides/additional-props.mdx b/website/pages/docs/guides/additional-props.mdx new file mode 100644 index 0000000..31b592e --- /dev/null +++ b/website/pages/docs/guides/additional-props.mdx @@ -0,0 +1,34 @@ +# Additional props + +To avoid unnecessary wrappers that just pass on some props to the rendered component or element, you can use the `.attrs` constructor. It allows you to attach additional props (or "attributes") to a component. + +## Usage + +Create an `input` of type "checkbox" with `twc`: + +```ts +const Checkbox = twc.input.attrs({ + type: "checkbox", +})`appearance-none size-4 border-2 border-blue-500 rounded-sm bg-white`; +``` + +## Adapting attributes based on props + +`attrs` accepts a function to generate attributes based on input props. + +In this example, we create an anchor that accepts an `$external` prop and adds `target` and `rel` attributes based on its value. + +```ts +import { twc, TwcComponentProps } from "react-twc"; + +type AnchorProps = TwcComponentProps<"a"> & { $external?: boolean }; + +// Accept an $external prop that adds `target` and `rel` attributes if present +const Anchor = twc.a.attrs((props) => + props.$external ? { target: "_blank", rel: "noopener noreferrer" } : {}, +)`appearance-none size-4 border-2 border-blue-500 rounded-sm bg-white`; + +render( + Class Variance Authority +); +``` diff --git a/website/pages/docs/guides/styling-any-component.mdx b/website/pages/docs/guides/styling-any-component.mdx index 38ccf41..8df763b 100644 --- a/website/pages/docs/guides/styling-any-component.mdx +++ b/website/pages/docs/guides/styling-any-component.mdx @@ -25,3 +25,11 @@ export default () => ( ); ``` + +If you need to specify some default props, you can use [additional props](/guides/additional-props). For example you can define the `sideOffset` by default using `attrs` constructor: + +```tsx +const HoverCardContent = twc(HoverCard.Content).attrs({ + sideOffset: 5, +})`data-[side=bottom]:animate-slideUpAndFade data-[side=right]:animate-slideLeftAndFade data-[side=left]:animate-slideRightAndFade data-[side=top]:animate-slideDownAndFade w-[300px] rounded-md bg-white p-5 shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px] data-[state=open]:transition-all`; +``` diff --git a/website/pages/index.mdx b/website/pages/index.mdx index 6bb5e1c..3c5b636 100644 --- a/website/pages/index.mdx +++ b/website/pages/index.mdx @@ -47,7 +47,7 @@ const Card = twc.div`rounded-lg border bg-slate-100 text-white shadow-sm`; With just one single line of code, you can create a reusable component with all these amazing features out-of-the-box: -- ⚡️ Lightweight — only 0.42kb +- ⚡️ Lightweight — only 0.44kb - ✨ Autocompletion in all editors - 🎨 Adapt the style based on props - ♻️ Reuse classes with `asChild` prop From 3e2f5536a9d37c8b6ab138898f9390bd18e81067 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Sat, 23 Dec 2023 19:23:27 +0400 Subject: [PATCH 2/2] docs: add TwcComponentProps to API ref --- website/pages/docs/api-reference.mdx | 18 ++++++++++++++---- website/pages/docs/guides/_meta.json | 6 +++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/website/pages/docs/api-reference.mdx b/website/pages/docs/api-reference.mdx index 89f421f..fb4cb30 100644 --- a/website/pages/docs/api-reference.mdx +++ b/website/pages/docs/api-reference.mdx @@ -2,7 +2,7 @@ List of exports available from `react-twc` package. -## `twc` +### `twc` Builds a `twc` component. @@ -12,7 +12,7 @@ import { twc } from "react-twc"; const Title = twc.h2`font-bold`; ``` -## `createTwc` +### `createTwc` Create a custom instance of `twc`. @@ -26,12 +26,12 @@ const twx = createTwc({ }); ``` -### Options +#### Options - `compose`: The compose function to use. Defaults to `clsx`. - `shouldForwardProp`: The function to use to determine if a prop should be forwarded to the underlying component. Defaults to `prop => prop[0] !== "$"`. -## `cx` +### `cx` Concatenates class names (an alias of [`clsx`](https://github.com/lukeed/clsx)). @@ -40,3 +40,13 @@ import { cx } from "react-twc"; const className = cx(classes); ``` + +### `TwcComponentProps` + +Returns props accepted by a `twc` component. Similar to `React.ComponentProps<"button">` with `asChild` prop and `className` type from `clsx`. + +```tsx +import type { TwcComponentProps } from "react-twc"; + +type ButtonProps = TwcComponentProps<"button">; +``` diff --git a/website/pages/docs/guides/_meta.json b/website/pages/docs/guides/_meta.json index 8a0683e..b6e4a5b 100644 --- a/website/pages/docs/guides/_meta.json +++ b/website/pages/docs/guides/_meta.json @@ -1,4 +1,8 @@ { "as-child-prop": "asChild prop", - "class-name-prop": "className prop" + "class-name-prop": "className prop", + "refs": "Refs", + "styling-any-component": "Styling any component", + "additional-props": "Additional props", + "adapting-based-on-props": "Adapting based on props" }