Allow wrapping any React component with an Angular one on-the-fly. (#106)

This commit is contained in:
Ben Grynhaus 2019-03-30 16:27:39 +03:00 коммит произвёл Ben Grynhaus
Родитель 4bb71a8395
Коммит b756043226
20 изменённых файлов: 220 добавлений и 31 удалений

Просмотреть файл

@ -9,6 +9,11 @@
</ol>
</div>
<h5>Generic React component wrapper</h5>
<my-counter [count]="count" (onIncrement)="reactCustomOnIncrement($event)">
<div style="text-transform: uppercase;color:red">test</div>
</my-counter>
<fab-checkbox label="foo" [renderLabel]="renderCheckboxLabel"></fab-checkbox>
<div style="width:500px">

Просмотреть файл

@ -178,6 +178,12 @@ export class AppComponent {
this.onDecrement = this.onDecrement.bind(this);
}
count = 3;
reactCustomOnIncrement(newCount: number) {
this.count = newCount;
}
customItemCount = 1;
// FIXME: Allow declarative syntax too

Просмотреть файл

@ -1,4 +1,4 @@
import { AngularReactBrowserModule } from '@angular-react/core';
import { AngularReactBrowserModule, wrapComponent } from '@angular-react/core';
import {
FabBreadcrumbModule,
FabButtonModule,
@ -35,11 +35,18 @@ import {
FabSpinButtonModule,
FabTextFieldModule,
} from '@angular-react/fabric';
import { NgModule } from '@angular/core';
import { NgModule, NO_ERRORS_SCHEMA } from '@angular/core';
import { NxModule } from '@nrwl/nx';
import { initializeIcons } from 'office-ui-fabric-react/lib/Icons';
import { AppComponent } from './app.component';
import { CounterComponent } from './counter/counter.component';
import { CounterProps, Counter } from './counter/react-counter';
const MyCounterComponent = wrapComponent<CounterProps>({
ReactComponent: Counter,
selector: 'my-counter',
// propNames: ['count', 'onIncrement'], // needed if propTypes are not defined on `ReactComponent`
});
@NgModule({
imports: [
@ -80,8 +87,9 @@ import { CounterComponent } from './counter/counter.component';
FabSpinButtonModule,
FabTextFieldModule,
],
declarations: [AppComponent, CounterComponent],
declarations: [AppComponent, CounterComponent, MyCounterComponent],
bootstrap: [AppComponent],
schemas: [NO_ERRORS_SCHEMA],
})
export class AppModule {
constructor() {

Просмотреть файл

@ -0,0 +1,27 @@
import * as React from 'react';
import * as PropTypes from 'prop-types';
export interface CounterProps {
count?: number;
onIncrement?: (count: number) => void;
}
export const Counter: React.FC<CounterProps> = ({ count = 0, onIncrement, children, ...rest } = {}) => {
return (
<button {...rest} onClick={() => onIncrement(count + 1)}>
<div>
<h5>children:</h5>
{children}
</div>
<div>
<h5>count:</h5>
{count}
</div>
</button>
);
};
Counter.propTypes = {
count: PropTypes.number,
onIncrement: PropTypes.func,
};

Просмотреть файл

@ -2,7 +2,8 @@
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc/apps/demo",
"module": "es2015"
"module": "es2015",
"jsx": "react"
},
"include": ["**/*.ts"],
"exclude": ["**/*.spec.ts", "src/test.ts"]

Просмотреть файл

@ -0,0 +1,131 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
import * as React from 'react';
import {
Component,
ElementRef,
ViewChild,
ChangeDetectionStrategy,
Input,
ChangeDetectorRef,
Renderer2,
NgZone,
Output,
EventEmitter,
Type,
} from '@angular/core';
declare const __decorate: typeof import('tslib').__decorate;
import { ReactWrapperComponent } from './wrapper-component';
import { registerElement } from '../renderer/registry';
export interface WrapComponentOptions<TProps extends object> {
/**
* The type of the component to wrap.
*/
ReactComponent: React.ComponentType<TProps>;
/**
* The selector to use.
*/
selector: string;
/**
* The prop names to pass to the `reactComponent`, if any.
* Note that any prop starting with `on` will be converted to an `Output`, and other to `Input`s.
*
* @note If `reactComponent` has `propTypes`, this can be omitted.
*/
propNames?: string[];
/**
* @see `WrapperComponentOptions#setHostDisplay`.
*/
setHostDisplay?: boolean;
/**
* An _optional_ callback for specified wether a prop should be considered an `Output`.
* @default propName => propName.startsWith('on')
*/
isOutputProp?: (propName: string) => boolean;
}
/**
* Gets the display name of a component.
* @param WrappedComponent The type of the wrapper component
*/
function getDisplayName<TProps extends object>(WrappedComponent: React.ComponentType<TProps>): string {
return WrappedComponent.displayName || WrappedComponent.name || 'Component';
}
/**
* Checks if the propName is an output one.
* Currently uses a simple check - anything that starts with `on` is considered an output prop.
*/
function defaultIsOutputProp(propName: string): boolean {
return propName.startsWith('on');
}
function getPropNames<TProps extends object>(ReactComponent: React.ComponentType<TProps>) {
if (!ReactComponent.propTypes) {
return null;
}
return Object.keys(ReactComponent.propTypes);
}
/**
* Wrap a React component with an Angular one.
*
* @template TProps The type of props of the underlying React element.
* @param options Options for wrapping the component.
* @returns A class of a wrapper Angular component.
*/
export function wrapComponent<TProps extends object>(
options: Readonly<WrapComponentOptions<TProps>>
): Type<ReactWrapperComponent<TProps>> {
const Tag = getDisplayName(options.ReactComponent);
registerElement(Tag, () => options.ReactComponent);
const propNames = options.propNames || getPropNames(options.ReactComponent);
const isOutputProp = options.isOutputProp || defaultIsOutputProp;
const inputProps = propNames.filter(propName => !isOutputProp(propName));
const outputProps = propNames.filter(isOutputProp);
const inputPropsBindings = inputProps.map(propName => `[${propName}]="${propName}"`);
const outputPropsBindings = outputProps.map(propName => `(${propName})="${propName}.emit($event)"`);
const propsBindings = [...inputPropsBindings, ...outputPropsBindings].join('\n');
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
styles: ['react-renderer'],
selector: options.selector,
template: `
<${Tag}
#reactNode
${propsBindings}
>
<ReactContent><ng-content></ng-content></ReactContent>
</${Tag}>
`,
})
class WrapperComponent extends ReactWrapperComponent<TProps> {
@ViewChild('reactNode') protected reactNodeRef: ElementRef;
constructor(elementRef: ElementRef, changeDetectorRef: ChangeDetectorRef, renderer: Renderer2, ngZone: NgZone) {
super(elementRef, changeDetectorRef, renderer, { ngZone, setHostDisplay: options.setHostDisplay });
outputProps.forEach(propName => {
this[propName] = new EventEmitter<any>();
});
}
}
inputProps.forEach(propName => __decorate([Input()], WrapperComponent.prototype, propName));
outputProps.forEach(propName => __decorate([Output()], WrapperComponent.prototype, propName));
return WrapperComponent;
}

Просмотреть файл

@ -12,6 +12,7 @@ import {
Renderer2,
SimpleChanges,
AfterContentInit,
ɵBaseDef,
} from '@angular/core';
import classnames from 'classnames';
import toStyle from 'css-to-style';
@ -21,9 +22,10 @@ import { Many } from '../declarations/many';
import { ReactContentProps } from '../renderer/react-content';
import { isReactNode } from '../renderer/react-node';
import { isReactRendererData } from '../renderer/renderer';
import { toObject } from '../utils/object/to-object';
import { fromPairs } from '../utils/object/from-pairs';
import { afterRenderFinished } from '../utils/render/render-delay';
import { InputRendererOptions, JsxRenderFunc, createInputJsxRenderer, createRenderPropHandler } from './render-props';
import { omit } from '../utils/object/omit';
// 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)
const ignoredAttributeMatchers = [/^_?ng-?.*/, /^style$/, /^class$/];
@ -231,17 +233,23 @@ export abstract class ReactWrapperComponent<TProps extends {}> implements AfterC
);
const eventListeners = this.elementRef.nativeElement.getEventListeners();
// Event listeners already being handled natively by the derived component
const handledEventListeners = Object.keys(
((this.constructor as any).ngBaseDef as ɵBaseDef<any>).outputs
) as (keyof typeof eventListeners)[];
const unhandledEventListeners = omit(eventListeners, ...handledEventListeners);
const eventHandlersProps =
eventListeners && Object.keys(eventListeners).length
? toObject(
Object.values(eventListeners).map<[string, React.EventHandler<React.SyntheticEvent>]>(([eventListener]) => [
eventListener.type,
(ev: React.SyntheticEvent) => eventListener.listener(ev && ev.nativeEvent),
])
unhandledEventListeners && Object.keys(unhandledEventListeners).length
? fromPairs(
Object.values(unhandledEventListeners).map<[string, React.EventHandler<React.SyntheticEvent>]>(
([eventListener]) => [
eventListener.type,
(ev: React.SyntheticEvent) => eventListener.listener(ev && ev.nativeEvent),
]
)
)
: {};
{
}
this.reactNodeRef.nativeElement.setProperties({ ...props, ...eventHandlersProps });
}

Просмотреть файл

@ -1,3 +1,6 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
// prettier-ignore
/**
* Get the known keys (i.e. no index signature) of T.

Просмотреть файл

@ -1 +1,4 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
export type Many<T> = T | T[];

Просмотреть файл

@ -1,7 +1,7 @@
/**
* Transforms an array of [key, value] tuples to an object
*/
export function toObject<T extends [string, any][]>(pairs: T): object {
export function fromPairs<T extends [PropertyKey, any][]>(pairs: T): object {
return pairs.reduce(
(acc, [key, value]) =>
Object.assign(acc, {

Просмотреть файл

@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
import { Omit } from '@angular-react/core';
import { Omit } from '../../declarations/omit';
/**
* Omit a a set of properties from an object.

Просмотреть файл

@ -3,12 +3,15 @@
export { AngularReactBrowserModule } from './lib/angular-react-browser.module';
export * from './lib/components/wrapper-component';
export * from './lib/components/generic-wrap-component';
export * from './lib/declarations/public-api';
export * from './lib/renderer/components/Disguise';
export { getPassProps, passProp, PassProp } from './lib/renderer/pass-prop-decorator';
export { createReactContentElement, ReactContent, ReactContentProps } from './lib/renderer/react-content';
export * from './lib/renderer/react-template';
export { registerElement } from './lib/renderer/registry';
export { registerElement, ComponentResolver } from './lib/renderer/registry';
export * from './lib/utils/object/omit';
export {
JsxRenderFunc,
RenderComponentOptions,

Просмотреть файл

@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
import { InputRendererOptions, JsxRenderFunc, ReactWrapperComponent } from '@angular-react/core';
import { InputRendererOptions, JsxRenderFunc, ReactWrapperComponent, omit } from '@angular-react/core';
import {
ChangeDetectorRef,
ElementRef,
@ -23,7 +23,6 @@ import { IContextualMenuItem } from 'office-ui-fabric-react';
import { Subscription } from 'rxjs';
import { CommandBarItemChangedPayload } from '../command-bar/directives/command-bar-item.directives';
import { mergeItemChanges } from '../core/declarative/item-changed';
import { omit } from '../../utils/omit';
import { getDataAttributes } from '../../utils/get-data-attributes';
export abstract class FabBaseButtonComponent extends ReactWrapperComponent<IButtonProps>

Просмотреть файл

@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
import { InputRendererOptions, KnownKeys, ReactWrapperComponent } from '@angular-react/core';
import { InputRendererOptions, KnownKeys, ReactWrapperComponent, omit } from '@angular-react/core';
import {
AfterContentInit,
ChangeDetectionStrategy,
@ -22,7 +22,6 @@ import { ICommandBarItemProps, ICommandBarProps } from 'office-ui-fabric-react/l
import { IContextualMenuItem } from 'office-ui-fabric-react/lib/ContextualMenu';
import { Subscription } from 'rxjs';
import { OnChanges, TypedChanges } from '../../declarations/angular/typed-changes';
import omit from '../../utils/omit';
import { mergeItemChanges } from '../core/declarative/item-changed';
import { CommandBarItemChangedPayload, CommandBarItemDirective } from './directives/command-bar-item.directives';
import {

Просмотреть файл

@ -18,7 +18,7 @@ import {
Renderer2,
ViewChild,
} from '@angular/core';
import { InputRendererOptions, JsxRenderFunc, ReactWrapperComponent } from '@angular-react/core';
import { InputRendererOptions, JsxRenderFunc, ReactWrapperComponent, omit } from '@angular-react/core';
import {
DetailsListBase,
IColumn,
@ -32,7 +32,6 @@ import { IListProps } from 'office-ui-fabric-react/lib/List';
import { Subscription } from 'rxjs';
import { OnChanges, TypedChanges } from '../../declarations/angular/typed-changes';
import { omit } from '../../utils/omit';
import { mergeItemChanges } from '../core/declarative/item-changed';
import { ChangeableItemsDirective } from '../core/shared/changeable-items.directive';
import { IDetailsListColumnOptions } from './directives/details-list-column.directive';

Просмотреть файл

@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
import { InputRendererOptions, Omit, ReactWrapperComponent } from '@angular-react/core';
import { InputRendererOptions, Omit, ReactWrapperComponent, omit } from '@angular-react/core';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
@ -15,7 +15,6 @@ import {
ViewChild,
} from '@angular/core';
import { IExpandingCardProps, IHoverCardProps, IPlainCardProps } from 'office-ui-fabric-react/lib/HoverCard';
import { omit } from '../../utils/omit';
@Component({
selector: 'fab-hover-card',

Просмотреть файл

@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
import { InputRendererOptions, JsxRenderFunc, Omit, ReactWrapperComponent } from '@angular-react/core';
import { InputRendererOptions, JsxRenderFunc, Omit, ReactWrapperComponent, omit } from '@angular-react/core';
import { ChangeDetectorRef, ElementRef, EventEmitter, Input, NgZone, OnInit, Output, Renderer2 } from '@angular/core';
import { IPersonaProps } from 'office-ui-fabric-react/lib/Persona';
import {
@ -10,7 +10,6 @@ import {
IBasePickerSuggestionsProps,
IPickerItemProps,
} from 'office-ui-fabric-react/lib/Pickers';
import omit from '../../../utils/omit';
export abstract class FabBasePickerComponent<T, TProps extends IBasePickerProps<T>>
extends ReactWrapperComponent<TProps>
@ -19,7 +18,7 @@ export abstract class FabBasePickerComponent<T, TProps extends IBasePickerProps<
@Input() resolveDelay?: IBasePickerProps<T>['resolveDelay'];
@Input() defaultSelectedItems?: IBasePickerProps<T>['defaultSelectedItems'];
@Input() getTextFromItem?: IBasePickerProps<T>['getTextFromItem'];
@Input() className?: IBasePickerProps<T>['className'];
@Input() className?: IBasePickerProps<T>['className'];
@Input() pickerCalloutProps?: IBasePickerProps<T>['pickerCalloutProps'];
@Input() searchingText?: IBasePickerProps<T>['searchingText'];
@Input() disabled?: IBasePickerProps<T>['disabled'];

Просмотреть файл

@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
import { InputRendererOptions, Omit, ReactWrapperComponent } from '@angular-react/core';
import { InputRendererOptions, Omit, ReactWrapperComponent, omit } from '@angular-react/core';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
@ -16,7 +16,6 @@ import {
} from '@angular/core';
import { IButtonProps } from 'office-ui-fabric-react/lib/Button';
import { ISearchBoxProps } from 'office-ui-fabric-react/lib/SearchBox';
import omit from '../../utils/omit';
@Component({
selector: 'fab-search-box',

Просмотреть файл

@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
import { InputRendererOptions, Omit, ReactWrapperComponent } from '@angular-react/core';
import { InputRendererOptions, Omit, ReactWrapperComponent, omit } from '@angular-react/core';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
@ -15,7 +15,6 @@ import {
ViewChild,
} from '@angular/core';
import { ITooltipHostProps, ITooltipProps } from 'office-ui-fabric-react/lib/Tooltip';
import { omit } from '../../utils/omit';
@Component({
selector: 'fab-tooltip-host',

Просмотреть файл

@ -10,6 +10,7 @@
"typeRoots": ["node_modules/@types"],
"lib": ["es2017", "dom"],
"baseUrl": ".",
"jsx": "react",
"skipLibCheck": true,
"paths": {
"@angular-react/*": ["libs/*"],