Skip to content

Commit b756043

Browse files
bengryBen Grynhaus
authored and
Ben Grynhaus
committed
Allow wrapping any React component with an Angular one on-the-fly. (#106)
1 parent 4bb71a8 commit b756043

20 files changed

+220
-31
lines changed

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

Lines changed: 5 additions & 0 deletions
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-checkbox label="foo" [renderLabel]="renderCheckboxLabel"></fab-checkbox>
1318

1419
<div style="width:500px">

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,12 @@ export class AppComponent {
178178
this.onDecrement = this.onDecrement.bind(this);
179179
}
180180

181+
count = 3;
182+
183+
reactCustomOnIncrement(newCount: number) {
184+
this.count = newCount;
185+
}
186+
181187
customItemCount = 1;
182188

183189
// 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
@@ -12,6 +12,7 @@ import {
1212
Renderer2,
1313
SimpleChanges,
1414
AfterContentInit,
15+
ɵBaseDef,
1516
} from '@angular/core';
1617
import classnames from 'classnames';
1718
import toStyle from 'css-to-style';
@@ -21,9 +22,10 @@ import { Many } from '../declarations/many';
2122
import { ReactContentProps } from '../renderer/react-content';
2223
import { isReactNode } from '../renderer/react-node';
2324
import { isReactRendererData } from '../renderer/renderer';
24-
import { toObject } from '../utils/object/to-object';
25+
import { fromPairs } from '../utils/object/from-pairs';
2526
import { afterRenderFinished } from '../utils/render/render-delay';
2627
import { InputRendererOptions, JsxRenderFunc, createInputJsxRenderer, createRenderPropHandler } from './render-props';
28+
import { omit } from '../utils/object/omit';
2729

2830
// 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)
2931
const ignoredAttributeMatchers = [/^_?ng-?.*/, /^style$/, /^class$/];
@@ -231,17 +233,23 @@ export abstract class ReactWrapperComponent<TProps extends {}> implements AfterC
231233
);
232234

233235
const eventListeners = this.elementRef.nativeElement.getEventListeners();
236+
// Event listeners already being handled natively by the derived component
237+
const handledEventListeners = Object.keys(
238+
((this.constructor as any).ngBaseDef as ɵBaseDef<any>).outputs
239+
) as (keyof typeof eventListeners)[];
240+
const unhandledEventListeners = omit(eventListeners, ...handledEventListeners);
241+
234242
const eventHandlersProps =
235-
eventListeners && Object.keys(eventListeners).length
236-
? toObject(
237-
Object.values(eventListeners).map<[string, React.EventHandler<React.SyntheticEvent>]>(([eventListener]) => [
238-
eventListener.type,
239-
(ev: React.SyntheticEvent) => eventListener.listener(ev && ev.nativeEvent),
240-
])
243+
unhandledEventListeners && Object.keys(unhandledEventListeners).length
244+
? fromPairs(
245+
Object.values(unhandledEventListeners).map<[string, React.EventHandler<React.SyntheticEvent>]>(
246+
([eventListener]) => [
247+
eventListener.type,
248+
(ev: React.SyntheticEvent) => eventListener.listener(ev && ev.nativeEvent),
249+
]
250+
)
241251
)
242252
: {};
243-
{
244-
}
245253

246254
this.reactNodeRef.nativeElement.setProperties({ ...props, ...eventHandlersProps });
247255
}

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, {

libs/fabric/src/lib/utils/omit.ts renamed to libs/core/src/lib/utils/object/omit.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4-
import { Omit } from '@angular-react/core';
4+
import { Omit } from '../../declarations/omit';
55

66
/**
77
* Omit a a set of properties from an object.

libs/core/src/public-api.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@
33

44
export { AngularReactBrowserModule } from './lib/angular-react-browser.module';
55
export * from './lib/components/wrapper-component';
6+
export * from './lib/components/generic-wrap-component';
67
export * from './lib/declarations/public-api';
78
export * from './lib/renderer/components/Disguise';
89
export { getPassProps, passProp, PassProp } from './lib/renderer/pass-prop-decorator';
910
export { createReactContentElement, ReactContent, ReactContentProps } from './lib/renderer/react-content';
1011
export * from './lib/renderer/react-template';
11-
export { registerElement } from './lib/renderer/registry';
12+
export { registerElement, ComponentResolver } from './lib/renderer/registry';
13+
14+
export * from './lib/utils/object/omit';
1215
export {
1316
JsxRenderFunc,
1417
RenderComponentOptions,

libs/fabric/src/lib/components/button/base-button.component.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4-
import { InputRendererOptions, JsxRenderFunc, ReactWrapperComponent } from '@angular-react/core';
4+
import { InputRendererOptions, JsxRenderFunc, ReactWrapperComponent, omit } from '@angular-react/core';
55
import {
66
ChangeDetectorRef,
77
ElementRef,
@@ -23,7 +23,6 @@ import { IContextualMenuItem } from 'office-ui-fabric-react';
2323
import { Subscription } from 'rxjs';
2424
import { CommandBarItemChangedPayload } from '../command-bar/directives/command-bar-item.directives';
2525
import { mergeItemChanges } from '../core/declarative/item-changed';
26-
import { omit } from '../../utils/omit';
2726
import { getDataAttributes } from '../../utils/get-data-attributes';
2827

2928
export abstract class FabBaseButtonComponent extends ReactWrapperComponent<IButtonProps>

libs/fabric/src/lib/components/command-bar/command-bar.component.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4-
import { InputRendererOptions, KnownKeys, ReactWrapperComponent } from '@angular-react/core';
4+
import { InputRendererOptions, KnownKeys, ReactWrapperComponent, omit } from '@angular-react/core';
55
import {
66
AfterContentInit,
77
ChangeDetectionStrategy,
@@ -22,7 +22,6 @@ import { ICommandBarItemProps, ICommandBarProps } from 'office-ui-fabric-react/l
2222
import { IContextualMenuItem } from 'office-ui-fabric-react/lib/ContextualMenu';
2323
import { Subscription } from 'rxjs';
2424
import { OnChanges, TypedChanges } from '../../declarations/angular/typed-changes';
25-
import omit from '../../utils/omit';
2625
import { mergeItemChanges } from '../core/declarative/item-changed';
2726
import { CommandBarItemChangedPayload, CommandBarItemDirective } from './directives/command-bar-item.directives';
2827
import {

libs/fabric/src/lib/components/details-list/details-list.component.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
Renderer2,
1919
ViewChild,
2020
} from '@angular/core';
21-
import { InputRendererOptions, JsxRenderFunc, ReactWrapperComponent } from '@angular-react/core';
21+
import { InputRendererOptions, JsxRenderFunc, ReactWrapperComponent, omit } from '@angular-react/core';
2222
import {
2323
DetailsListBase,
2424
IColumn,
@@ -32,7 +32,6 @@ import { IListProps } from 'office-ui-fabric-react/lib/List';
3232
import { Subscription } from 'rxjs';
3333

3434
import { OnChanges, TypedChanges } from '../../declarations/angular/typed-changes';
35-
import { omit } from '../../utils/omit';
3635
import { mergeItemChanges } from '../core/declarative/item-changed';
3736
import { ChangeableItemsDirective } from '../core/shared/changeable-items.directive';
3837
import { IDetailsListColumnOptions } from './directives/details-list-column.directive';

libs/fabric/src/lib/components/hover-card/hover-card.component.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4-
import { InputRendererOptions, Omit, ReactWrapperComponent } from '@angular-react/core';
4+
import { InputRendererOptions, Omit, ReactWrapperComponent, omit } from '@angular-react/core';
55
import {
66
ChangeDetectionStrategy,
77
ChangeDetectorRef,
@@ -15,7 +15,6 @@ import {
1515
ViewChild,
1616
} from '@angular/core';
1717
import { IExpandingCardProps, IHoverCardProps, IPlainCardProps } from 'office-ui-fabric-react/lib/HoverCard';
18-
import { omit } from '../../utils/omit';
1918

2019
@Component({
2120
selector: 'fab-hover-card',

0 commit comments

Comments
 (0)