Skip to content
This repository has been archived by the owner on Sep 7, 2022. It is now read-only.

Support for attributes, type-extension CEs, and those loaded by HTML import #60

Closed
wants to merge 14 commits into from
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Converts web components into React components so that you can use them as first
- Listen for custom events triggered from web components declaratively using the standard `on*` syntax.
- Passes React `props` to web components as properties instead of attributes.
- Works with any web component library that uses a standard native custom element constructor, not just Skate or native web components.
- Also supports custom elements that have been loaded using HTML imports, as well as type-extension elements.

## Usage

Expand All @@ -22,6 +23,9 @@ const CustomElement = window.customElements.define('my-component', MyComponent);

// Reactify it!
export default reactify(CustomElement);

// You can also Reactify it using the tag name.
export default reactify('my-component');
```

Usage with [SkateJS](https://github.com/skatejs/skatejs) is pretty much the same, Skate just makes defining your custom element easier:
Expand Down Expand Up @@ -68,6 +72,67 @@ When you pass down props to the web component, instead of setting attributes lik
<MyComponent items={elem.items} callback={elem.callback} />
```

### Reactifying web components loaded using HTML import
Custom elements that depend on HTML imports (which are described in another part of the Web Components specification) were previously a bit trickier to integrate into a React project. But this `react-integration` library combined with [the `web-components` Webpack loader](https://github.com/rnicholus/web-components-loader) make the process farily painless. After integrating the Webpack loader into your project, simply `import` the root HTML file of the web component in your React component and use the generated URL to import the Web component using an HTML import in your `render` method. For example:

```jsx
import React, { Component } from 'react'

import reactify from 'skatejs-react-integration'

const importWcUrl = require('my-web-component/component.html')

class MyWebComponentWrapper extends Component {
render() {
const MyComponent = reactify('my-web-component')

return (
<span>
<link rel='import' href={ importWcUrl } />
<MyComponent />
</span>
)
}
}

export default MyWebComponentWrapper
```

### Web component attributes
If the underlying web component you intend to Reactify requires some properties to be set directly on the element as attributes, include an `attr-` prefix on the property name. For example:

```jsx
<MyComponent attr-data-fo='bar' />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if it used something like attributes={{ foo: 'bar' }}?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like a reasonable alternative.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've also been playing around with a similar pattern in a separate lib but added support for more special props https://github.com/skatejs/dom-diff#attributes.

```

The above code will set a `data-foo` attribute on the underlying custom element, instead of setting a corresponding property on the element object. An example of such a web component is [the hugely popular `<x-gif>` element](https://github.com/geelen/x-gif), which requires the GIF `src` to be set as an element and _not_ a property.

### Type-extension elements
Custom elements that extend an existing native element are also supported. Take [the ajax-form element](https://github.com/rnicholus/ajax-form) as an example. Ajax-form extends the native `<form>` element to provide additional features. This is an example of a type-extension element. In order to use any type-extension element, such as ajax-form, your render method might look something like this:

```jsx
render() {
const Form = reactify('form')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My first thought would be to do reactify('ajax-form') here instead, and not have to specify is on the component.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will that work? That will result in the library rendering an element named ajax-form, but this isn't a valid tag name as far as I can tell. It's a form element.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I commented in the particular parts of the code that could handle this. Essentially, you'd try and look up the component with customElements.get(name) and then construct it using the constructor. Underneath the hood you could tell React to use the real tagName and set the is attribute.

I'm unsure how this will work, though. The v1 CE polyfill doesn't support customised built-ins, I don't think. I also don't think React will call createElement() with the correct arguments in order for them to work, even if it did (or the browser natively supported them).

Copy link
Author

@rnicholus rnicholus Jan 9, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I commented in the particular parts of the code that could handle this.

I'll take some time to look over those comments ASAP.

Essentially, you'd try and look up the component with customElements.get(name) and then construct it using the constructor

Most of this PR is driven to better support CEs loaded using HTML imports. During development, I spent quite a bit of time integrating my own CEs as well as a few other CEs that are loaded via HTML import (some of these are mentioned the docs I contributed). When HTML imports are involved, there is no guarantee that the CE constructor will be available by the time reactify is called. In that case, it seemed safer to rely on the tag name and allow the rendered element to be upgraded at some time after the element specification was loaded by the HTML import. This timing issue was what drove most of the changes in this PR.

I also don't think React will call createElement() with the correct arguments in order for them to work

My testing seemed to suggest that, as long as the is property is included on the native element as part of its first render, the customized built-in works as expected. But if is is added later, the element is never upgraded. That is why I included the change at https://github.com/webcomponents/react-integration/pull/60/files#diff-1fdf421c05c1140f6d71444ea2b27638R81, to ensure a customized built-in element is rendered correctly.

The v1 CE polyfill doesn't support customised built-ins, I don't think

That very well may be true. The v1 CE polyfill is fairly new to me. I have only recently started using it in new WC projects. But if this is missing, it seems this support will need to be added, since the is property used to mark an extended native element is still part of the spec as far as I know. Granted, all of my testing was with v0 polyfills.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 for automatically adding the is="" attribute without each instance having to put it on there. The v1 CE polyfill may soon have support for customized built-in, pending webcomponents/custom-elements#42.

Once skatejs-react-integration has support for customized builtins (when this pr is merged), myself and the company I work for are interested in using this. Love the work you're doing here!


return (
<span>
<link rel='import' href={ ajaxFormImportUrl } />
<Form attr-action='/user'
is='ajax-form'
attr-method='POST'
onSubmit={ this.props.onSubmit }
>
<input name='name' value={ this.props.username } />
<input name='address' value={ this.props.address } />
<button>Submit</button>
</Form>
</span>
)
}
```

Notice that the above example also makes use of attribute and HTML imported elemenent support, both of which are discussed earlier in the documentation.

### Children

If your web component renders content to itself, make sure you're using Shadow DOM and that you render it to the shadow root. If you do this `children` and props get passed down as normal and React won't see your content in the shadow root.
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,8 @@
"dist",
"src"
],
"version": "2.0.2"
"version": "2.1.0",
"engines": {
"node": ">=6.0.0"
}
}
2 changes: 1 addition & 1 deletion src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ import * as React from 'react';
type CustomElementCtor = { new(...args: any[]): HTMLElement };
type ReactComponentCtor = { new(...args: any[]): React.Component<any, any> };
type Options = { React?: any, ReactDOM?: any };
export default function (CustomElement: CustomElementCtor, opts: Options = {}): ReactComponentCtor;
export default function (CustomElementOrTagName: CustomElementCtor | string, opts: Options = {}): ReactComponentCtor;
51 changes: 37 additions & 14 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,20 @@ function syncEvent(node, eventName, newEventHandler) {
}
}

export default function (CustomElement, opts) {
export default function (CustomElementOrTagName, opts) {
opts = assign({}, defaults, opts);
if (typeof CustomElement !== 'function') {
throw new Error('Given element is not a valid constructor');
if (typeof CustomElementOrTagName !== 'function' && typeof CustomElementOrTagName !== 'string') {
throw new Error('Given element is not a valid constructor or tag name');
}
const tagName = (new CustomElement()).tagName;

let CustomElementConstructor = CustomElementOrTagName
if (typeof CustomElementOrTagName === 'string') {
CustomElementConstructor = document.createElement(CustomElementOrTagName).constructor
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could use customElements.get(CustomElementOrTagName). This would have the added benefit of solving https://github.com/webcomponents/react-integration/pull/60/files#r95096012, I think.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will return undefined if the element hasn't been imported yet.

}

const tagName = typeof CustomElementOrTagName === 'string'
? CustomElementOrTagName
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You wouldn't need this part if looking up via customElemenst.get() but you would need to use the is attribute if it exists and fallback to tagName if not.

: (new CustomElementOrTagName()).tagName;
const displayName = pascalCase(tagName);
const { React, ReactDOM } = opts;

Expand All @@ -50,25 +58,40 @@ export default function (CustomElement, opts) {
const node = ReactDOM.findDOMNode(this);
Object.keys(props).forEach(name => {
if (name === 'children' || name === 'style') {
return;
}
return;
}

if (name.indexOf('on') === 0 && name[2] === name[2].toUpperCase()) {
syncEvent(node, name.substring(2), props[name]);
} else {
node[name] = props[name];
if (name.indexOf('on') === 0 && name[2] === name[2].toUpperCase()) {
syncEvent(node, name.substring(2), props[name]);
} else if (name.indexOf('attr-') === 0) {
let attrValue = props[name];
if (typeof attrValue === 'object') {
attrValue = JSON.stringify(attrValue)
}
});
node.setAttribute(name.substring(5), attrValue)
} else {
node[name] = props[name];
}
});
}
render() {
return React.createElement(tagName, { style: this.props.style }, this.props.children);
return React.createElement(
tagName,
{
is: this.props.is,
style: this.props.style
},
this.props.children
);
}
}

const proto = CustomElement.prototype;
const proto = typeof CustomElementOrTagName === 'string'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since there's already CustomElementConstructor above, could you do CustomElementConstructor.prototype?

? document.createElement(tagName).constructor.prototype
: CustomElementOrTagName.prototype;
Object.getOwnPropertyNames(proto).forEach(prop => {
Object.defineProperty(ReactComponent.prototype, prop, Object.getOwnPropertyDescriptor(proto, prop));
});
});

return ReactComponent;
}
1 change: 1 addition & 0 deletions test/unit.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import './unit/attributes';
import './unit/children';
import './unit/events';
import './unit/display-name';
Expand Down
38 changes: 38 additions & 0 deletions test/unit/attributes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import reactify from '../../src/index';
import React from 'react';
import ReactDOM from 'react-dom';

let x = 0;

function createComponent() {
const tagName = `x-attributes-${x++}`;

return {
constructor: document.registerElement(tagName, {
prototype: Object.create(HTMLElement.prototype),
}),
tagName: tagName
};
}

function getReactifiedComponentByConstructor() {
return reactify(createComponent().constructor);
}

function getReactifiedComponentByTagName() {
return reactify(createComponent().tagName);
}

describe('attributes', () => {
it('should pass on properties that start with "attr-" as attributes', () => {
const Comp = getReactifiedComponentByConstructor();
const comp = ReactDOM.render(<Comp attr-data-test='test-data'/>, window.fixture);
expect(ReactDOM.findDOMNode(comp).getAttribute('data-test')).to.equal('test-data');
});

it('should pass on properties that start with "attr-" as attributes using tagName based reactification', () => {
const Comp = getReactifiedComponentByTagName();
const comp = ReactDOM.render(<Comp attr-data-test='test-data'/>, window.fixture);
expect(ReactDOM.findDOMNode(comp).getAttribute('data-test')).to.equal('test-data');
});
});
30 changes: 26 additions & 4 deletions test/unit/children.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,38 @@ import React from 'react';
import ReactDOM from 'react-dom';

let x = 0;

function createComponent() {
return reactify(document.registerElement(`x-children-${x++}`, {
prototype: Object.create(HTMLElement.prototype),
}), { React, ReactDOM });
const tagName = `x-children-${x++}`;

return {
constructor: document.registerElement(tagName, {
prototype: Object.create(HTMLElement.prototype),
}),
tagName: tagName
};
}

function getReactifiedComponentByConstructor() {
return reactify(createComponent().constructor, { React, ReactDOM });
}

function getReactifiedComponentByTagName() {
return reactify(createComponent().tagName, { React, ReactDOM });
}

describe('children', () => {
it('should pass on children', () => {
const Comp = createComponent();
const Comp = getReactifiedComponentByConstructor();
const comp = ReactDOM.render(<Comp><child /></Comp>, window.fixture);
expect(ReactDOM.findDOMNode(comp).tagName).to.match(/^X-CHILDREN/);
expect(ReactDOM.findDOMNode(comp).firstChild.tagName).to.equal('CHILD');
});

it('should pass on children using tagName based reactification', () => {
const Comp = getReactifiedComponentByTagName();
const comp = ReactDOM.render(<Comp><child /></Comp>, window.fixture);
expect(ReactDOM.findDOMNode(comp).tagName).to.match(/^X-CHILDREN/);
expect(ReactDOM.findDOMNode(comp).firstChild.tagName).to.equal('CHILD');
});
});
10 changes: 10 additions & 0 deletions test/unit/display-name.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,14 @@ describe('display-name', () => {
ReactDOM.render(React.createElement(Comp), window.fixture);
expect(Comp.displayName).to.equal('XDisplayName_1');
});

it('should be a PasalCased version of the tagName when reactifying using CE tagName', () => {
document.registerElement('x-display-name-2', {
prototype: Object.create(HTMLElement.prototype),
});

const Comp = reactify('x-display-name-2', { React, ReactDOM });
ReactDOM.render(React.createElement(Comp), window.fixture);
expect(Comp.displayName).to.equal('XDisplayName_2');
});
});
75 changes: 54 additions & 21 deletions test/unit/webcomponent-proto-funcs.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,44 +2,77 @@ import reactify from '../../src/index';

let x = 0;
function createComponent() {
return reactify(document.registerElement(`x-webcomponent-proto-funcs-${x++}`, {
prototype: Object.create(HTMLElement.prototype, {
prop: {
value: 'prop',
},
foo: {
value() {
return 'bar';
const tagName = `x-webcomponent-proto-funcs-${x++}`;
return {
constructor: document.registerElement(tagName, {
prototype: Object.create(HTMLElement.prototype, {
prop: {
value: 'prop',
},
},
getProp: {
value() {
return this.prop;
foo: {
value() {
return 'bar';
},
},
},
getter: {
get() {
throw new Error('should not throw when reactifying');
getProp: {
value() {
return this.prop;
},
},
},
getter: {
get() {
throw new Error('should not throw when reactifying');
},
},
}),
}),
}));
tagName: tagName
};
}

function getReactifiedComponentByConstructor() {
return reactify(createComponent().constructor);
}

function getReactifiedComponentByTagName() {
return reactify(createComponent().tagName);
}


describe('Webcomponent prototype functions', () => {
it('should be callable on React component', () => {
const Comp = createComponent();
const Comp = getReactifiedComponentByConstructor();
expect(Comp.prototype.foo()).to.equal('bar');
});

it('should return prop', () => {
const Comp = getReactifiedComponentByConstructor();
expect(Comp.prototype.getProp()).to.equal('prop');
});

it('should not invoke getters', () => {
// If this functionality fails, calling createComponent() should cause the error to be thrown.
const Comp = getReactifiedComponentByConstructor();

// We expect it to throw here to make sure we've written our test correctly.
expect(() => Comp.prototype.getter).to.throw();
});
});

describe('Webcomponent prototype functions - reactified with tagName', () => {
it('should be callable on React component', () => {
const Comp = getReactifiedComponentByTagName();
expect(Comp.prototype.foo()).to.equal('bar');
});

it('should return prop', () => {
const Comp = createComponent();
const Comp = getReactifiedComponentByTagName();
expect(Comp.prototype.getProp()).to.equal('prop');
});

it('should not invoke getters', () => {
// If this functionality fails, calling createComponent() should cause the error to be thrown.
const Comp = createComponent();
const Comp = getReactifiedComponentByTagName();

// We expect it to throw here to make sure we've written our test correctly.
expect(() => Comp.prototype.getter).to.throw();
Expand Down