Skip to content

Commit f4a7ae7

Browse files
authored
feat: react wrappers (#2530)
1 parent 7055add commit f4a7ae7

File tree

12 files changed

+200
-22
lines changed

12 files changed

+200
-22
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
"@patternfly/elements": patch
3+
---
4+
`<pf-accordion>`: update the `expandedIndex` DOM property on change

.changeset/tools-react-wrappers.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@patternfly/pfe-tools": minor
3+
"@patternfly/elements": minor
4+
---
5+
**React**: automatically generate react wrapper components

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ pfe.min.js
2727
*.tsbuildinfo
2828
test-results
2929

30+
/elements/react
31+
3032
elements/**/_temp
3133
core/**/_temp
3234
tools/**/_temp

elements/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@
5555
"./pf-timestamp/pf-timestamp.js": "./pf-timestamp/pf-timestamp.js",
5656
"./pf-tooltip/BaseTooltip.js": "./pf-tooltip/BaseTooltip.js",
5757
"./pf-tooltip/pf-tooltip.js": "./pf-tooltip/pf-tooltip.js",
58-
"./pf-popover/pf-popover.js": "./pf-popover/pf-popover.js"
58+
"./pf-popover/pf-popover.js": "./pf-popover/pf-popover.js",
59+
"./react/*": "./react/*"
5960
},
6061
"publishConfig": {
6162
"access": "public",

elements/pf-accordion/BaseAccordion.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ import type { TemplateResult } from 'lit';
33
import { LitElement, html } from 'lit';
44
import { property } from 'lit/decorators/property.js';
55

6-
import { observed } from '@patternfly/pfe-core/decorators.js';
7-
86
import { NumberListConverter, ComposedEvent } from '@patternfly/pfe-core';
97
import { Logger } from '@patternfly/pfe-core/controllers/logger.js';
108

@@ -52,6 +50,8 @@ export abstract class BaseAccordion extends LitElement {
5250

5351
#headerIndex = new RovingTabindexController<BaseAccordionHeader>(this);
5452

53+
#expandedIndex: number[] = [];
54+
5555
/**
5656
* Sets and reflects the currently expanded accordion 0-based indexes.
5757
* Use commas to separate multiple indexes.
@@ -61,18 +61,26 @@ export abstract class BaseAccordion extends LitElement {
6161
* </pf-accordion>
6262
* ```
6363
*/
64-
@observed(async function expandedIndexChanged(this: BaseAccordion, oldVal: unknown, newVal: unknown) {
65-
if (oldVal && oldVal !== newVal) {
66-
await this.collapseAll();
67-
for (const i of this.expandedIndex) {
68-
await this.expand(i, this);
69-
}
70-
}
71-
})
7264
@property({
7365
attribute: 'expanded-index',
7466
converter: NumberListConverter
75-
}) expandedIndex: number[] = [];
67+
})
68+
get expandedIndex() {
69+
return this.#expandedIndex;
70+
}
71+
72+
set expandedIndex(value) {
73+
const old = this.#expandedIndex;
74+
this.#expandedIndex = value;
75+
if (JSON.stringify(old) !== JSON.stringify(value)) {
76+
this.requestUpdate('expandedIndex', old);
77+
this.collapseAll().then(async () => {
78+
for (const i of this.expandedIndex) {
79+
await this.expand(i, this);
80+
}
81+
});
82+
}
83+
}
7684

7785
get headers() {
7886
return this.#allHeaders();
@@ -168,6 +176,7 @@ export abstract class BaseAccordion extends LitElement {
168176
#expandHeader(header: BaseAccordionHeader, index = this.#getIndex(header)) {
169177
// If this index is not already listed in the expandedSets array, add it
170178
this.expandedSets.add(index);
179+
this.#expandedIndex = [...this.expandedSets as Set<number>];
171180
header.expanded = true;
172181
}
173182

package-lock.json

Lines changed: 19 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@
5656
"build:lightdom",
5757
"build:elements",
5858
"build:create",
59-
"build:bundle"
59+
"build:bundle",
60+
"build:react"
6061
]
6162
},
6263
"analyze": {
@@ -168,6 +169,22 @@
168169
"elements/pfe.min.js"
169170
]
170171
},
172+
"build:react": {
173+
"command": "ts-node --esm scripts/generate-react-wrappers.ts",
174+
"dependencies": [
175+
"build:core",
176+
"build:tools",
177+
"analyze"
178+
],
179+
"files": [
180+
"elements/custom-elements.json",
181+
"scripts/generate-react-wrappers.ts",
182+
"tools/pfe-tools/react/generate-wrappers.ts"
183+
],
184+
"output": [
185+
"react/**/*"
186+
]
187+
},
171188
"build:lightdom": {
172189
"command": "npm run build:lightdom --workspaces --if-present",
173190
"dependencies": [
@@ -307,5 +324,8 @@
307324
"./core/*",
308325
"./elements",
309326
"./tools/*"
310-
]
327+
],
328+
"dependencies": {
329+
"@lit-labs/react": "^1.2.0"
330+
}
311331
}

scripts/generate-react-wrappers.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { generateReactWrappers } from '@patternfly/pfe-tools/react/generate-wrappers.js';
2+
3+
const inURL = new URL('../elements/custom-elements.json', import.meta.url);
4+
const outURL = new URL('../elements/react/', import.meta.url);
5+
6+
await generateReactWrappers(inURL, outURL);

tools/pfe-tools/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"./11ty/plugins/order-tags.cjs": "./11ty/plugins/order-tags.cjs",
3333
"./11ty/plugins/table-of-contents.cjs": "./11ty/plugins/table-of-contents.cjs",
3434
"./11ty/plugins/todos.cjs": "./11ty/plugins/todos.cjs",
35+
"./react/generate-wrappers.js": "./react/generate-wrappers.js",
3536
"./test/a11y-snapshot.js": "./test/a11y-snapshot.js",
3637
"./test/config.js": "./test/config.js",
3738
"./test/create-fixture.js": "./test/create-fixture.js",
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import type * as CEM from 'custom-elements-manifest';
2+
import javascript from 'dedent';
3+
import { dirname, join, relative } from 'node:path';
4+
import { fileURLToPath } from 'node:url';
5+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
6+
7+
function isCustomElementDeclaration(declaration: CEM.Declaration): declaration is CEM.CustomElementDeclaration {
8+
return !!(declaration as CEM.CustomElementDeclaration).customElement;
9+
}
10+
11+
function getDeprefixedClassName(className: string) {
12+
const upper = className.replace('Pf', '');
13+
return `${upper.charAt(0).toUpperCase()}${upper.slice(1)}`;
14+
}
15+
16+
function camel(str: string): string {
17+
return str.toLowerCase().replace(/[^a-zA-Z0-9]+(.)/g, (m, chr) => chr.toUpperCase());
18+
}
19+
20+
function getEventReactPropName(event: CEM.Event) {
21+
return camel(`on-${event.name}`);
22+
}
23+
24+
class NonCriticalError extends Error { }
25+
26+
async function writeReactWrapper(
27+
module: CEM.Module,
28+
decl: CEM.CustomElementDeclaration,
29+
outDirPathOrURL: string | URL,
30+
) {
31+
const { path, exports } = module;
32+
if (!exports) {
33+
throw new Error(`module has no exports: ${path}`);
34+
}
35+
const ceExport = exports.find(ex => ex.declaration.name === decl.name);
36+
if (!ceExport) {
37+
throw new Error(`module does not export custom element class: ${decl.name}`);
38+
}
39+
const { tagName } = decl;
40+
if (!tagName) {
41+
throw new NonCriticalError(`declaration does not have a tag name: ${decl.name}`);
42+
} else {
43+
const { name: Class } = ceExport;
44+
const events = decl.events ?? [];
45+
const outDirPath =
46+
typeof outDirPathOrURL === 'string' ? outDirPathOrURL
47+
: fileURLToPath(outDirPathOrURL);
48+
const outPath = join(outDirPath, path);
49+
await mkdir(dirname(outPath), { recursive: true });
50+
await writeFile(outPath, javascript`// ${path}
51+
import { createComponent } from '@lit-labs/react';
52+
import react from 'react';
53+
import { ${Class} as elementClass } from '@patternfly/elements/${module.path}';
54+
export const ${getDeprefixedClassName(Class)} = createComponent({
55+
tagName: '${decl.tagName}',
56+
elementClass,
57+
react,
58+
events: {${events.map(event => `
59+
${getEventReactPropName(event)}: '${event.name}'`).join(',')}${events.length ? `,
60+
` : ''}},
61+
});
62+
63+
`, 'utf8');
64+
return { tagName, outPath };
65+
}
66+
}
67+
68+
function isPackage(manifest: unknown): manifest is CEM.Package {
69+
const maybeManifest = (manifest as CEM.Package);
70+
return Array.isArray(maybeManifest?.modules) && !!maybeManifest.schemaVersion;
71+
}
72+
73+
async function parseManifest(maybeManifest: unknown): Promise<CEM.Package> {
74+
let manifest;
75+
if (maybeManifest instanceof URL ||
76+
typeof maybeManifest === 'string') {
77+
manifest = JSON.parse(await readFile(maybeManifest, 'utf-8'));
78+
} else {
79+
manifest = maybeManifest;
80+
}
81+
if (!isPackage(manifest)) {
82+
throw new Error('could not parse manifest');
83+
} else {
84+
return manifest;
85+
}
86+
}
87+
88+
export async function generateReactWrappers(
89+
customElementsManifestOrPathOrURL: CEM.Package | string | URL,
90+
outDirPathOrURL: string | URL,
91+
) {
92+
const manifest = await parseManifest(customElementsManifestOrPathOrURL);
93+
const written = [];
94+
try {
95+
for (const module of manifest.modules) {
96+
for (const decl of module.declarations ?? []) {
97+
if (isCustomElementDeclaration(decl)) {
98+
written.push(await writeReactWrapper(module, decl, outDirPathOrURL));
99+
}
100+
}
101+
}
102+
} catch (error) {
103+
if (error instanceof NonCriticalError) {
104+
// eslint-disable-next-line no-console
105+
console.info(error.message);
106+
} else {
107+
throw error;
108+
}
109+
}
110+
console.group('Wrote React Wrappers');
111+
for (const { tagName, outPath } of written) {
112+
console.log(`${tagName}: ${relative(process.cwd(), outPath)}`);
113+
}
114+
console.groupEnd();
115+
}

0 commit comments

Comments
 (0)