Skip to content

Commit 2f2fcbc

Browse files
author
Ben Grynhaus
committed
Allow wrapping any React component with an Angular one on-the-fly.
1 parent a93575c commit 2f2fcbc

File tree

19 files changed

+225
-32
lines changed

19 files changed

+225
-32
lines changed

apps/demo/src/app/app.component.html

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ <h2>Getting up and running...</h2>
99
</ol>
1010
</div>
1111

12+
<h5>Generic React component wrapper</h5>
13+
<my-counter [count]="count" (onIncrement)="reactCustomOnIncrement($event)">
14+
<div style="text-transform: uppercase;color:red">test</div>
15+
</my-counter>
16+
1217
<fab-dropdown
1318
[label]="'test label'"
1419
[selectedKey]="selectedItem && selectedItem.key"
@@ -19,7 +24,12 @@ <h2>Getting up and running...</h2>
1924
(onBlur)="logEvent('dropdown blur', $event)"
2025
></fab-dropdown>
2126

22-
<fab-dropdown contentStyle="width: 300px;" [placeholder]="'Select one'" [defaultSelectedKey]="'A'" (onChange)="logEvent('dropdown change', $event)">
27+
<fab-dropdown
28+
contentStyle="width: 300px;"
29+
[placeholder]="'Select one'"
30+
[defaultSelectedKey]="'A'"
31+
(onChange)="logEvent('dropdown change', $event)"
32+
>
2333
<options>
2434
<fab-dropdown-option optionKey="A" text="See option A"></fab-dropdown-option>
2535
<fab-dropdown-option optionKey="B" text="See option B"></fab-dropdown-option>

apps/demo/src/app/app.component.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,12 @@ export class AppComponent {
169169
this.onDecrement = this.onDecrement.bind(this);
170170
}
171171

172+
count = 3;
173+
174+
reactCustomOnIncrement(newCount: number) {
175+
this.count = newCount;
176+
}
177+
172178
customItemCount = 1;
173179

174180
// FIXME: Allow declarative syntax too

apps/demo/src/app/app.module.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { AngularReactBrowserModule } from '@angular-react/core';
1+
import { AngularReactBrowserModule, wrapComponent } from '@angular-react/core';
22
import {
33
FabBreadcrumbModule,
44
FabButtonModule,
@@ -35,11 +35,18 @@ import {
3535
FabSpinButtonModule,
3636
FabTextFieldModule,
3737
} from '@angular-react/fabric';
38-
import { NgModule } from '@angular/core';
38+
import { NgModule, NO_ERRORS_SCHEMA } from '@angular/core';
3939
import { NxModule } from '@nrwl/nx';
4040
import { initializeIcons } from 'office-ui-fabric-react/lib/Icons';
4141
import { AppComponent } from './app.component';
4242
import { CounterComponent } from './counter/counter.component';
43+
import { CounterProps, Counter } from './counter/react-counter';
44+
45+
const MyCounterComponent = wrapComponent<CounterProps>({
46+
ReactComponent: Counter,
47+
selector: 'my-counter',
48+
// propNames: ['count', 'onIncrement'], // needed if propTypes are not defined on `ReactComponent`
49+
});
4350

4451
@NgModule({
4552
imports: [
@@ -80,8 +87,9 @@ import { CounterComponent } from './counter/counter.component';
8087
FabSpinButtonModule,
8188
FabTextFieldModule,
8289
],
83-
declarations: [AppComponent, CounterComponent],
90+
declarations: [AppComponent, CounterComponent, MyCounterComponent],
8491
bootstrap: [AppComponent],
92+
schemas: [NO_ERRORS_SCHEMA],
8593
})
8694
export class AppModule {
8795
constructor() {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import * as React from 'react';
2+
import * as PropTypes from 'prop-types';
3+
4+
export interface CounterProps {
5+
count?: number;
6+
onIncrement?: (count: number) => void;
7+
}
8+
9+
export const Counter: React.FC<CounterProps> = ({ count = 0, onIncrement, children, ...rest } = {}) => {
10+
return (
11+
<button {...rest} onClick={() => onIncrement(count + 1)}>
12+
<div>
13+
<h5>children:</h5>
14+
{children}
15+
</div>
16+
<div>
17+
<h5>count:</h5>
18+
{count}
19+
</div>
20+
</button>
21+
);
22+
};
23+
24+
Counter.propTypes = {
25+
count: PropTypes.number,
26+
onIncrement: PropTypes.func,
27+
};

apps/demo/tsconfig.app.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
"extends": "../../tsconfig.json",
33
"compilerOptions": {
44
"outDir": "../../dist/out-tsc/apps/demo",
5-
"module": "es2015"
5+
"module": "es2015",
6+
"jsx": "react"
67
},
78
"include": ["**/*.ts"],
89
"exclude": ["**/*.spec.ts", "src/test.ts"]
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import * as React from 'react';
5+
import {
6+
Component,
7+
ElementRef,
8+
ViewChild,
9+
ChangeDetectionStrategy,
10+
Input,
11+
ChangeDetectorRef,
12+
Renderer2,
13+
NgZone,
14+
Output,
15+
EventEmitter,
16+
Type,
17+
} from '@angular/core';
18+
19+
declare const __decorate: typeof import('tslib').__decorate;
20+
21+
import { ReactWrapperComponent } from './wrapper-component';
22+
import { registerElement } from '../renderer/registry';
23+
24+
export interface WrapComponentOptions<TProps extends object> {
25+
/**
26+
* The type of the component to wrap.
27+
*/
28+
ReactComponent: React.ComponentType<TProps>;
29+
30+
/**
31+
* The selector to use.
32+
*/
33+
selector: string;
34+
35+
/**
36+
* The prop names to pass to the `reactComponent`, if any.
37+
* Note that any prop starting with `on` will be converted to an `Output`, and other to `Input`s.
38+
*
39+
* @note If `reactComponent` has `propTypes`, this can be omitted.
40+
*/
41+
propNames?: string[];
42+
43+
/**
44+
* @see `WrapperComponentOptions#setHostDisplay`.
45+
*/
46+
setHostDisplay?: boolean;
47+
48+
/**
49+
* An _optional_ callback for specified wether a prop should be considered an `Output`.
50+
* @default propName => propName.startsWith('on')
51+
*/
52+
isOutputProp?: (propName: string) => boolean;
53+
}
54+
55+
/**
56+
* Gets the display name of a component.
57+
* @param WrappedComponent The type of the wrapper component
58+
*/
59+
function getDisplayName<TProps extends object>(WrappedComponent: React.ComponentType<TProps>): string {
60+
return WrappedComponent.displayName || WrappedComponent.name || 'Component';
61+
}
62+
63+
/**
64+
* Checks if the propName is an output one.
65+
* Currently uses a simple check - anything that starts with `on` is considered an output prop.
66+
*/
67+
function defaultIsOutputProp(propName: string): boolean {
68+
return propName.startsWith('on');
69+
}
70+
71+
function getPropNames<TProps extends object>(ReactComponent: React.ComponentType<TProps>) {
72+
if (!ReactComponent.propTypes) {
73+
return null;
74+
}
75+
76+
return Object.keys(ReactComponent.propTypes);
77+
}
78+
79+
/**
80+
* Wrap a React component with an Angular one.
81+
*
82+
* @template TProps The type of props of the underlying React element.
83+
* @param options Options for wrapping the component.
84+
* @returns A class of a wrapper Angular component.
85+
*/
86+
export function wrapComponent<TProps extends object>(
87+
options: Readonly<WrapComponentOptions<TProps>>
88+
): Type<ReactWrapperComponent<TProps>> {
89+
const Tag = getDisplayName(options.ReactComponent);
90+
registerElement(Tag, () => options.ReactComponent);
91+
92+
const propNames = options.propNames || getPropNames(options.ReactComponent);
93+
const isOutputProp = options.isOutputProp || defaultIsOutputProp;
94+
95+
const inputProps = propNames.filter(propName => !isOutputProp(propName));
96+
const outputProps = propNames.filter(isOutputProp);
97+
98+
const inputPropsBindings = inputProps.map(propName => `[${propName}]="${propName}"`);
99+
const outputPropsBindings = outputProps.map(propName => `(${propName})="${propName}.emit($event)"`);
100+
const propsBindings = [...inputPropsBindings, ...outputPropsBindings].join('\n');
101+
102+
@Component({
103+
changeDetection: ChangeDetectionStrategy.OnPush,
104+
styles: ['react-renderer'],
105+
selector: options.selector,
106+
template: `
107+
<${Tag}
108+
#reactNode
109+
${propsBindings}
110+
>
111+
<ReactContent><ng-content></ng-content></ReactContent>
112+
</${Tag}>
113+
`,
114+
})
115+
class WrapperComponent extends ReactWrapperComponent<TProps> {
116+
@ViewChild('reactNode') protected reactNodeRef: ElementRef;
117+
118+
constructor(elementRef: ElementRef, changeDetectorRef: ChangeDetectorRef, renderer: Renderer2, ngZone: NgZone) {
119+
super(elementRef, changeDetectorRef, renderer, { ngZone, setHostDisplay: options.setHostDisplay });
120+
121+
outputProps.forEach(propName => {
122+
this[propName] = new EventEmitter<any>();
123+
});
124+
}
125+
}
126+
127+
inputProps.forEach(propName => __decorate([Input()], WrapperComponent.prototype, propName));
128+
outputProps.forEach(propName => __decorate([Output()], WrapperComponent.prototype, propName));
129+
130+
return WrapperComponent;
131+
}

libs/core/src/lib/components/wrapper-component.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
TemplateRef,
1818
Type,
1919
AfterContentInit,
20+
ɵBaseDef,
2021
} from '@angular/core';
2122
import classnames from 'classnames';
2223
import toStyle from 'css-to-style';
@@ -26,9 +27,10 @@ import { ReactContentProps } from '../renderer/react-content';
2627
import { isReactNode } from '../renderer/react-node';
2728
import { isReactRendererData } from '../renderer/renderer';
2829
import { createComponentRenderer, createHtmlRenderer, createTemplateRenderer } from '../renderer/renderprop-helpers';
29-
import { toObject } from '../utils/object/to-object';
30+
import { fromPairs } from '../utils/object/from-pairs';
3031
import { afterRenderFinished } from '../utils/render/render-delay';
3132
import { unreachable } from '../utils/types/unreachable';
33+
import { omit } from '../utils/object/omit';
3234

3335
// Forbidden attributes are still ignored, since they may be set from the wrapper components themselves (forbidden is only applied for users of the wrapper components)
3436
const ignoredAttributeMatchers = [/^_?ng-?.*/, /^style$/, /^class$/];
@@ -284,17 +286,23 @@ export abstract class ReactWrapperComponent<TProps extends {}> implements AfterC
284286
);
285287

286288
const eventListeners = this.elementRef.nativeElement.getEventListeners();
289+
// Event listeners already being handled natively by the derived component
290+
const handledEventListeners = Object.keys(
291+
((this.constructor as any).ngBaseDef as ɵBaseDef<any>).outputs
292+
) as (keyof typeof eventListeners)[];
293+
const unhandledEventListeners = omit(eventListeners, ...handledEventListeners);
294+
287295
const eventHandlersProps =
288-
eventListeners && Object.keys(eventListeners).length
289-
? toObject(
290-
Object.values(eventListeners).map<[string, React.EventHandler<React.SyntheticEvent>]>(([eventListener]) => [
291-
eventListener.type,
292-
(ev: React.SyntheticEvent) => eventListener.listener(ev && ev.nativeEvent),
293-
])
296+
unhandledEventListeners && Object.keys(unhandledEventListeners).length
297+
? fromPairs(
298+
Object.values(unhandledEventListeners).map<[string, React.EventHandler<React.SyntheticEvent>]>(
299+
([eventListener]) => [
300+
eventListener.type,
301+
(ev: React.SyntheticEvent) => eventListener.listener(ev && ev.nativeEvent),
302+
]
303+
)
294304
)
295305
: {};
296-
{
297-
}
298306

299307
this.reactNodeRef.nativeElement.setProperties({ ...props, ...eventHandlersProps });
300308
}

libs/core/src/lib/declarations/known-keys.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
14
// prettier-ignore
25
/**
36
* Get the known keys (i.e. no index signature) of T.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
14
export type Many<T> = T | T[];

libs/core/src/lib/utils/object/to-object.ts renamed to libs/core/src/lib/utils/object/from-pairs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* Transforms an array of [key, value] tuples to an object
33
*/
4-
export function toObject<T extends [string, any][]>(pairs: T): object {
4+
export function fromPairs<T extends [PropertyKey, any][]>(pairs: T): object {
55
return pairs.reduce(
66
(acc, [key, value]) =>
77
Object.assign(acc, {

0 commit comments

Comments
 (0)