@papit/react
v0.0.2
Published
Generates typed React 19 wrapper components for a web component package from its source, so the elements can be used as React components with props, refs and on-event handlers.
Maintainers
Readme
@papit/react
Generates typed React 19 wrapper components for a web component package from its source, so the elements can be used as React components with props, refs and on-event handlers.
Installation
npm install @papit/reactOr run it without installing:
npx @papit/reactUsage
From inside a web component package, write src/react-wrapper.tsx:
npx @papit/reactAs a package script:
{
"scripts": {
"generate:react": "npx @papit/react"
}
}Using the result (here for @papit/tabs, exported as @papit/tabs/react):
import { Tabs, Tab, TabPanel } from "@papit/tabs/react";
export function Example() {
return (
<Tabs value="a" onChange={(event) => console.log(event.target)}>
<Tab value="a">First</Tab>
<TabPanel value="a">First panel</TabPanel>
</Tabs>
);
}Importing @papit/tabs/react also types the raw tags, e.g. <pap-tab value="a" />.
From code:
import { analysePackage } from "@papit/analyse";
import { generateReact, writeReact } from "@papit/react";
writeReact("packages/web/design-system/atoms/tabs", { output: "/tmp/tabs.tsx" });
const code = generateReact(analysePackage("packages/web/design-system/atoms/tabs"));CLI flags
| Flag | Default | Description |
| --- | --- | --- |
| -o <file> | src/react-wrapper.tsx | Output file, relative to the package. |
API
writeReact
writeReact(location: string, options?: { output?: string }): {
file: string; status: "written" | "skipped"; stale?: { file: string; removed: boolean };
}Analyses the package in-process with @papit/analyse (no analyse.json needed) and writes the wrappers. It returns skipped when the file exists without the marker line (first, or second after "use client";).
With the default output, a src/react-wrapper.ts from before the .tsx is handled too, as bundle-js would still pick it first: deleted when it has the marker (stale.removed), kept with a warning when it was taken over by hand. A custom -o never touches it.
generateReact
generateReact(analysis: PackageAnalysis): stringThe source of the wrapper file for an analysis, without touching disk.
MARKER
"// generated by @papit/react", the line after "use client"; in every generated file.
The generated file
Plain React, the way you'd write it by hand: a props interface and a function component per custom element, named after its class. It's .tsx, which @papit/bundle-js and @papit/bundle-ts pick up when there is no .ts of the same name.
- SSR-safe.
"use client";comes first, and the package is only imported in the browser (if (typeof window !== "undefined") void import("@papit/tabs")), since defining elements needs the DOM. On the server the tags render as plain markup with their attributes. Every tag carriessuppressHydrationWarning, as the element writes its own host attributes on connect (role, tabindex, reflected defaults). - Readable. One line per prop, one
useEffectper event and one for the assigned props, no generic helpers. Each component returnsReactElement, so the emitted.d.tsstays short. - Intrinsic elements. The tags are declared in
React.JSX.IntrinsicElements, so<pap-tabs>is typed too.
It isn't called react.tsx: every web package sets "baseUrl": "src", so a bare react import inside src/react.tsx would resolve to the file itself.
| Part | Comes from |
| --- | --- |
| Props | One name?: Element["name"] line per @property field over the whole extends chain, with its JSDoc description. Readonly and plain fields are left out. Names stay as the element spells them. An override without a decorator (override variant = "outline") keeps the options of the @property it overrides. A protected @property with an attribute is named after the attribute, dashes dropped (ismodal on modal → modal, close-outside-click → closeoutsideclick), and typed from its resolved type. |
| How props reach the element | Strings, numbers, booleans and literal unions go through JSX, so they're server-rendered and React 19 sets them as properties once the element is defined. Aliases and indexed access types are judged by what @papit/analyse resolves them to (ButtonColor → "primary" \| … goes through JSX). One whose attribute name differs (hideStates → hide-states) goes through JSX under its attribute name. A boolean that defaults to true is rendered as "false" when false (loop={false} → loop="false"), since React would leave it out of the server markup and the element would boot with true. Objects, arrays, functions, unions with one of those (string \| Date), unresolved aliases and props without an attribute are assigned in a single effect per component after customElements.whenDefined, as an attribute would stringify or lose them. |
| Events | Every event becomes an on<PascalCase> prop (change → onChange, tab-select → onTabSelect), except those inherited from @papit/web-component (e.g. first-render). An effect adds the listener on the element and removes it on unmount or when the handler changes. A CustomEvent is typed with its detail when the detail needs no import, otherwise as plain CustomEvent. |
| ref | A normal prop (React 19), merged with the component's own ref. Object and callback refs both work, including callback cleanups. |
| HTML attributes | HTMLAttributes<Element>, minus every key that clashes with a prop or handler (e.g. React's own onChange). |
| Intrinsic element | The props interface, minus the handlers and the props the component doesn't pass through as they are, plus each renamed prop under its attribute name (<pap-confetti click>), typed from the props interface. A boolean that defaults to true also takes "false". |
The package is imported by its own name, for the element types and to define the elements. @papit/bundle-js keeps that import external in every entry but the main one, so customElements.define only runs once.
Taking it over
Delete the marker line and the file is yours: later runs print a message and leave it untouched.
Wiring a package
The package needs, besides the script:
exports["./react"], so the import path stays@papit/<pkg>/react:"./react": { "import": "./lib/react-wrapper.js", "types": "./lib/react-wrapper.d.ts" }react(^19) as a peerDependency, so it andreact/jsx-runtimestay out of the bundletsconfig
"jsx": "react-jsx"tsconfig
pathsmapping its own name to./index, which the create template already sets up
License
Licensed under the @Papit License 1.0 Copyright (c) 2024 Henry Pap (@onkelhoy)
Key points
- ✅ Free to use in commercial projects
- ✅ Free to modify and distribute
- ✅ Attribution required
- ❌ Cannot resell the component itself as a standalone product
See the LICENSE file for full details.
Related
- @papit/analyse Provides the analysis the wrappers are generated from.
- @papit/bundle-js
Bundles
src/react-wrapper.tsxwith the package's own name kept external. - @papit/web-component
The engine whose
@propertyfields become props.
