Vue3 Web Components events not working with custom wrapper - javascript

The WebComponents do work fine with this custom script but I have problems to.
I want to be able to add event listener from outside to listen to the custom Vue events fired from within the component (in this example on click on the WC). Thus I created an event proxy but for some reason the listener is never triggered. Obviously I made a mistake somewhere. So I hope that someone can point me to the right solution.
I made a CodeSandbox for you to take a look and play around with the code.
(as CodeSandbox has some issues you'll need to click on the reload button on the right side of the layout in order to make it work)
//main.js
import { createApp } from "vue";
import App from "./App.vue";
import WebComponentService from "./services/wc";
WebComponentService.init();
createApp(App).mount("#app");
Vue Component used as source file for Web Component
//src/wcs/MyComp.vue
<template>
<div class="m" #click="click">
Hello My Comp {{ name }}
<div v-for="(i, index) in values" :key="index">ID: {{ i.title }}</div>
</div>
</template>
<script>
export default {
__useShadowDom: false,
emits: ["my-click"],
// emits: ["my-click", "myclick"],
props: {
name: {
default: "Test",
},
values: {
type: Object,
default: () => {
return [{ title: "A" }, { title: "B" }];
},
},
},
methods: {
click() {
// emit the event
this.$emit("my-click");
console.log("EMIT");
},
},
};
</script>
<style lang="less" scoped>
.m {
border: 5px solid red;
margin: 10px;
padding: 10px;
border-radius: 5px;
}
</style>
Index.html where I test the web component and added an event listener
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="icon" href="<%= BASE_URL %>favicon.ico" />
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<div id="app"></div>
<!-- built files will be auto injected -->
<h3>WC Test here</h3>
<div class="component-canvas">
<!-- web component testing -->
<xvue-my-comp
id="vtest"
name="MyComp HTML Rendering"
:values='[{"title": "Element 1"}, {"title": "Element"}]'
>Inner HTML Caption</xvue-my-comp
>
</div>
<script>
const mySelector = document.querySelector("xvue-my-comp");
//listen to the event
mySelector.addEventListener("my-click", function (ev) {
console.log("#LISTEN");
alert(ev);
});
</script>
</body>
</html>
Custom WebComponents wrapper
import HTMLParsedElement from "html-parsed-element";
import { createApp, h, toHandlerKey } from "vue";
import { snakeCase, camelCase } from "lodash";
let registeredComponents = {};
const tagPrefix = "xvue-"; // Prefix for Custom Elements (HTML5)
const vueTagPrefix = "xvue-init-"; // Prefix for VUE Components - Must not be tagPrefix to avoid loops
// We use only one file as web component for simplicity
// and because codesandbox doesen't work with dynamic requires
const fileName = "MyComp.vue";
const webComponent = require(`../wcs/MyComp.vue`);
const componentName = snakeCase(
camelCase(
// Gets the file name regardless of folder depth
fileName
.split("/")
.pop()
.replace(/.ce/, "")
.replace(/\.\w+$/, "")
)
).replace(/_/, "-");
// store our component
registeredComponents[componentName] = {
component: webComponent.default
};
export default {
init() {
// HTMLParsedElement is a Polyfil Library (NPM Package) to give a clean parsedCallback as the browser
// default implementation has no defined callback when all props and innerHTML is available
class VueCustomElement extends HTMLParsedElement {
// eslint-disable-next-line
constructor() {
super();
}
parsedCallback() {
console.log("Legacy Component Init", this);
if (!this.getAttribute("_innerHTML")) {
this.setAttribute("data-initialized", this.innerHTML);
}
let rootNode = this;
let vueTagName = this.tagName
.toLowerCase()
.replace(tagPrefix, vueTagPrefix);
let compBaseName = this.tagName.toLowerCase().replace(tagPrefix, "");
let compConfig = registeredComponents[compBaseName];
// Optional: Shadow DOM Mode. Can be used by settings __useShadowDom in component vue file
if (compConfig.component.__useShadowDom) {
rootNode = this.attachShadow({ mode: "open" });
document
.querySelectorAll('head link[rel=stylesheet][href*="core."]')
.forEach((el) => {
rootNode.appendChild(el.cloneNode(true));
});
}
if (vueTagName) {
// If we have no type we do nothing
let appNode = document.createElement("div");
// let appNode = rootNode;
appNode.innerHTML +=
"<" +
vueTagName +
">" +
this.getAttribute("data-initialized") +
"</" +
vueTagName +
">"; // #TODO: Some issues with multiple objects being created via innerHTML and slots
rootNode.appendChild(appNode);
// eslint-disable-next-line #typescript-eslint/no-this-alias
const self = this;
function createCustomEvent(name, args) {
return new CustomEvent(name, {
bubbles: false,
cancelable: false,
detail: args.length === 1 ? args[0] : args
});
}
const createEventProxies = (eventNames) => {
const eventProxies = {};
if (eventNames) {
console.log("eventNames", eventNames);
// const handlerName = toHandlerKey(camelCase(name));
eventNames.forEach((name) => {
const handlerName = name;
eventProxies[handlerName] = (...args) => {
this.dispatchEvent(createCustomEvent(name, args));
};
});
}
return eventProxies;
};
const eventProxies = createEventProxies(compConfig.component.emits);
this._props = {};
const app = createApp({
render() {
let props = Object.assign({}, self._props, eventProxies);
// save our attributes as props
[...self.attributes].forEach((attr) => {
let newAttr = {};
newAttr[attr.nodeName] = attr.nodeValue;
props = Object.assign({}, props, newAttr);
});
console.log("props", props);
delete props.dataVApp;
return h(compConfig.component, props);
},
beforeCreate: function () {}
});
// Append only relevant VUE components for this tag
app.component(vueTagPrefix + compBaseName, compConfig.component);
this.vueObject = app.mount(appNode);
console.log("appNode", app.config);
}
}
disconnectedCallback() {
if (this.vueObject) {
this.vueObject.$destroy(); // Remove VUE Object
}
}
adoptedCallback() {
//console.log('Custom square element moved to new page.');
}
}
// Register for all available component tags ---------------------------
// Helper to Copy Classes as customElement.define requires separate Constructors
function cloneClass(parent) {
return class extends parent {};
}
for (const [name, component] of Object.entries(registeredComponents)) {
customElements.define(tagPrefix + name, cloneClass(VueCustomElement));
}
}
};

If I whack this into your render method, it all works fine.
Thus your problem is not Web Components or Events related
document.addEventListener("foo", (evt) => {
console.log("FOOd", evt.detail, evt.composedPath());
});
self.dispatchEvent(
new CustomEvent("foo", {
bubbles: true,
composed: true,
cancelable: false,
detail: self
})
);
addendum after comment:
Your code has bubbles:false - that will never work
This code properly emits an Event
You need to look into how you trigger your proxy code
const createCustomEvent = (name, args = []) => {
return new CustomEvent(name, {
bubbles: true,
composed: true,
cancelable: false,
detail: !args.length ? self : args.length === 1 ? args[0] : args
});
};
const createEventProxies = (eventNames) => {
const eventProxies = {};
if (eventNames) {
eventNames.forEach((name) => {
const handlerName =
"on" + name[0].toUpperCase() + name.substr(1).toLowerCase();
eventProxies[handlerName] = (...args) => {
appNode.dispatchEvent(createCustomEvent(name));
};
});
document.addEventListener("foo",evt=>console.warn("foo!",evt));
appNode.dispatchEvent(createCustomEvent("foo"));
console.log(
"#eventProxies",
eventProxies,
self,
"appnode:",
appNode,
"rootNode",
rootNode
);
}
return eventProxies;
};

I guess you could try to use Vue3's (since 3.2) defineCustomElement
import CustomElement from './some/path/CustomElement.ce.vue'
const CustomElementCE = defineCustomElement(CustomElement)
customElements.define('custom-element', CustomElementCE);
More on that here: Vue web components

I found one possible solution. As stated in the vue docs we can use static event listeners that are passed as props. They have to be prefixed with "on" i.E. onMyevent for event name my-event. Though it works fine - the downside is that I can't pass any arguments as I don't see where to get them from.
One strange thing is that I cant add this event to the instance. Something like self.dispatchEvent() doesn't work when placed inside createEventProxies method. So I have to stick to document.dispatchEvent()
Working CodeSandbox.
const createCustomEvent = (name, args = []) => {
return new CustomEvent(name, {
bubbles: false,
composed: true,
cancelable: false,
detail: !args.length ? self : args.length === 1 ? args[0] : args
});
};
const createEventProxies = (eventNames) => {
const eventProxies = {};
if (eventNames) {
eventNames.forEach((name) => {
const handlerName =
"on" + name[0].toUpperCase() + name.substr(1).toLowerCase();
eventProxies[handlerName] = (...args) => {
document.dispatchEvent(createCustomEvent(name));
};
});
console.log("#eventProxies", eventProxies);
}
return eventProxies;
};

Related

CKEditor 5 plugin development can't edit content

I am trying to develop a simple plugin for CKEditor 5. My ultimate goal is to have the plugin emit the following HTML:
<div class="icms_note">
<div class="span-6 module-note">
<p>User can edit here</p>
</div>
<div class="clear"></div>
</div>
I was having issues with the user editable area being in the wrong spot so I reduced the output to just this:
<div class="icms_note">
<p>User can edit here</p>
</div>
and the data model looks like this:
<icms_note>
<paragraph>User can edit here</paragraph>
</icms_note>
So I can get the data model created correctly and the HTML ends up in the editor the way I expect but I can't edit the text in the paragraph. I click on the text and the cursor just jumps out. I've tried looking at other examples and the tutorials but I can't seem to get it to work right. My plugin code is below. Any help would be appreciated.
note.js
import Plugin from "#ckeditor/ckeditor5-core/src/plugin";
import NoteEditing from "./noteediting";
import NoteUI from "./noteui";
export default class Note extends Plugin {
static get requires() {
return [ NoteEditing, NoteUI ];
}
static get pluginName() {
return "IcmsNote";
}
}
noteui.js
import Plugin from "#ckeditor/ckeditor5-core/src/plugin";
import { ButtonView } from "#ckeditor/ckeditor5-ui";
export default class NoteUI extends Plugin {
init() {
const editor = this.editor;
editor.ui.componentFactory.add( "icms_note", (locale) => {
const button = new ButtonView(locale);
button.set({
label: "Note",
withText: true,
tooltip: true
});
button.on( "execute", () => {
editor.execute("insertNote");
});
return button;
} );
}
}
noteediting.js
import Position from "#ckeditor/ckeditor5-engine/src/view/position";
import NoteCommand from "./notecommand";
export default class NoteEditing extends Plugin {
init() {
this._defineSchema();
this._defineConverters();
this.editor.commands.add("insertNote", new NoteCommand(this.editor));
}
_defineSchema() {
const schema = this.editor.model.schema;
schema.register( "icms_note", {
inheritAllFrom: "$text",
allowIn: [ "$root", "$container" ],
isInline: true
});
}
_defineConverters() {
const conversion = this.editor.conversion;
conversion.for( "downcast" ).elementToElement({
model: "icms_note",
view: ( modelElementValue, conversionApi ) => {
const { writer } = conversionApi;
const outerDivElement = writer.createEditableElement("div", {class: "icms_note"});
return outerDivElement;
}
});//<div><div class=\"span-6 module-note\"><p>Enter note</p></div><div class=\"clear\"></div></div>
conversion.for( "upcast" ).elementToElement({
view: {
name: "div",
attributes: {
classes: [ "icms_note" ]
}
},
model: {
key: "icms_note",
value: viewElement => {
const val = viewElement.getChildren()[0].getChildren()[0].data;
return val;
}
}
});
}
}
notecommand.js
import Command from "#ckeditor/ckeditor5-core/src/command";
export default class NoteCommand extends Command {
constructor(editor) {
super(editor);
}
execute() {
console.log("NoteCommand#execute");
const model = this.editor.model;
const selection = model.document.selection;
model.change( modelWriter => {
let position = selection.getFirstPosition();
const icmsNote = modelWriter.createElement("icms_note");
const paragraph = modelWriter.createElement("paragraph");
modelWriter.insert(paragraph, icmsNote);
modelWriter.insertText("User can edit here", paragraph);
let positionElementName = position.parent.name;
while (positionElementName != "$root" && positionElementName != "$container") {
position = model.createPositionAfter(position.parent);
positionElementName = position.parent.name;
}
model.insertContent(icmsNote, position, null, {
setSelection: "after"
});
});
}
}

Storybook with web components - changing arguments dynamically on code

I have a modal component and I'm writing the story for it. It looks something like this:
import { Story, Meta } from '#storybook/html';
export default {
title: 'Components/Modal',
argTypes: {
open: {
name: 'Opened',
control: 'boolean'
},
},
args: {
open: false,
}
} as Meta;
const Template: Story = (args) => {
return `
<my-modal open="${args.open}">
Some example content inside the modal
</my-modal>
`;
};
export const Modal: Story = Template.bind({});
I have the arg open on the controls and I can change its value to true and the modal shows. But I would like the story to have a button and when it's clicked, the modal shows.
I can't find a way to do this in the current version of Storybook for web components.
I've seen there are some hooks available for React (import { useArgs } from '#storybook/api';) that allows you to change the arguments value dynamically but I can't see how to do this for web components?
Any helps will be highly appreciated.
Just add that button to the template:
import { Story, Meta } from '#storybook/html';
export default {
title: 'Components/Modal',
argTypes: {
open: {
name: 'Opened',
control: 'boolean'
},
},
args: {
open: false,
}
} as Meta;
const Template: Story = (args) => {
return `
<button
type="button"
onclick="this.nextElementSibling.open = !this.nextElementSibling.open">
Toggle Modal
</button>
<my-modal .open=${args.open}>
Some example content inside the modal
</my-modal>
`;
};
export const Modal: Story = Template.bind({});
Also, for boolean attributes - if implemented properly -
you should work with the property (prefix it in the template with a .) rather than the attribute.
Doing that with all native code isn't rocket science...
<my-dialog id="DIALOG" open>
Hello *Native* Web Components world!
</my-dialog>
<button onclick="DIALOG.open()">OPEN</button>
<script>
customElements.define("my-dialog", class extends HTMLElement {
static get observedAttributes() {
return ["open"];
}
constructor() {
super() // sets and returns 'this'
.attachShadow({mode:"open"}) // sets and return this.shadowRoot
.innerHTML = `<dialog><slot></slot><button>Close</button></dialog>`;
this.dialog = this.shadowRoot.querySelector("dialog");
}
connectedCallback() {
this.onclick = () => this.close(); // or attach to button
}
attributeChangedCallback(name,oldValue,newValue) {
this.open();
}
open() {
this.dialog.showModal(); // or .show()
}
close() {
this.dialog.close();
}
});
</script>

Dynamically wrap text in an anchor tag in Quilljs Error: addRange(): The given range isn't in document

I'm looking to search the editor text for any hashtags, and wrap the hashtag in an anchor tag to make it a link. So this: #whatsup would be this: <a href='http://help.com'>#whatsup</a>.
Here's what I have so far, which modifies the text correctly but results in this error. addRange(): The given range isn't in document And the cursor is removed, disallowing me to type anything else.
I get the same error when using setContents rather than dangerouslyPasteHTML
Just type any hashtag value such as #foo into the code snippet and look at your console to reproduce the error. Any help much appreciated.
class RichEditor extends React.Component {
constructor (props) {
super(props)
this.state = {
editorHtml: '',
hashTags: []
}
this.modules = {
toolbar: [
[{ header: [1, 2, false] }],
['bold', 'italic', 'underline'],
['image', 'code-block']
]
}
this.editor = null;
this.quillRef = null;
this.handleChange = this.handleChange.bind(this)
}
handleChange (html) {
var blurtHashtags = this.editor.getText().match(/(?:^|\B)#[a-zA-Z][\w]{1,19}\b/g);
if (blurtHashtags) {
var modHtml = html.replace(blurtHashtags[0], "<a href='http://help.com'>" + blurtHashtags[0] + "</a>");
this.editor.clipboard.dangerouslyPasteHTML(modHtml);
} else {
this.setState(prevState => ({
editorHtml: html
}));
}
}
componentDidMount() {
if (typeof this.quillRef.getEditor !== 'function') return;
this.editor = this.quillRef.getEditor();
}
render() {
return (
<div>
<ReactQuill
ref={(el) => { this.quillRef = el; }}
onChange={this.handleChange}
modules={this.modules}
/>
</div>
);
}
}
ReactDOM.render( <
RichEditor / > ,
document.getElementById("quill")
);
<div id="quill"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.6.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.6.3/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/prop-types/prop-types.js"></script>
<script src="https://unpkg.com/react-quill#latest/dist/react-quill.js"></script>
<link href="//cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
It's pretty interesting what is going on in the background. The solution what you find down there uses setTimeout with 0ms delay to call dangerouslyPasteHTML function.
The reason behind why it is working now as expected that is technically not a good practice to modify the content in the handler event of the input field and by adding the delay the event loop will execute the input field modification - dangerouslyPasteHTML function - after the handler event.
The following code snippet resolves your issue, please take a look:
class RichEditor extends React.Component {
constructor (props) {
super(props)
this.state = {
editorHtml: '',
hashTags: []
}
this.modules = {
toolbar: [
[{ header: [1, 2, false] }],
['bold', 'italic', 'underline'],
['image', 'code-block']
]
}
this.editor = null;
this.quillRef = null;
this.handleChange = this.handleChange.bind(this)
}
handleChange (html) {
const linkStart = `<a href="http://help.com" rel="noopener noreferrer" target="_blank">`;
const linkEnd = `</a>`;
// remove anchor tag around the hashtag value
// in order to identify hashtag properly
let modHtml = html.replace(linkStart, '');
modHtml = modHtml.replace(linkEnd, '');
const blurtHashtags = modHtml.match(/(?:^|\B)#[a-zA-Z][\w]{1,19}\b/g);
if (blurtHashtags) {
// rebuild link with anchor tag
const link = `${linkStart}${blurtHashtags[0]}${linkEnd}`;
modHtml = modHtml.replace(blurtHashtags[0], link);
let editor = this.editor;
setTimeout(() => {
editor.clipboard.dangerouslyPasteHTML(modHtml);
let selection = editor.getSelection();
selection.index = modHtml.length;
editor.setSelection(selection);
}, 0);
} else {
this.setState(prevState => ({
editorHtml: html
}));
}
}
componentDidMount() {
if (typeof this.quillRef.getEditor !== 'function') return;
this.editor = this.quillRef.getEditor();
}
render() {
return (
<div>
<ReactQuill
className="quill-pre"
ref={(el) => { this.quillRef = el; }}
onChange={this.handleChange}
modules={this.modules}
/>
</div>
);
}
}
ReactDOM.render( <
RichEditor / > ,
document.getElementById("quill")
);
.quill-pre p {
white-space: pre;
}
<div id="quill"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.6.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.6.3/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/prop-types/prop-types.js"></script>
<script src="https://unpkg.com/react-quill#latest/dist/react-quill.js"></script>
<link href="//cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
Obviously the rich text editor is not perfect at this point but fits for your requirements which was removing the error message what you had earlier. If you try typing the test text value #wassup then it won't throw any error message.
Additionally I have added white-space: pre; styling for the p tag in the component which resolves the white spacing issue after typing any hashtag.
Hope this helps!

Manage focusing multiple TextInputs in react-native (more than 10+)

In my React Native application, there will be many TextInputs, and when the user presses the return button, I want react to focus on the next TextInput. Previous stackoverflow threads recommend using refs, but only have examples for 2 TextInputs. What would be the most efficient way to create lets say 30+ refs and handle focusing?
React Native 0.59 w/ regular old js.
I'm using this plugin
https://github.com/gcanti/tcomb-form-native
It allow me to pass inputs as Array, Thus it can be easy manipulate the array and set the focus dynamically.
You can do the following, First install the library
npm install tcomb-form-native
Then import the library and set your input
var t = require('tcomb-form-native');
var Person = t.struct({
name: t.String, // a required string
surname: t.maybe(t.String), // an optional string
age: t.Number, // a required number
rememberMe: t.Boolean // a boolean
});
Now you can customize fields options through an array. Note I just add a key called "next" this will allow me to loop through the array and focus on the next input dynamically in componentDidMount
let options = {
fields: {
name: {
returnKeyType: "next",
next: "surname"
},
surname: {
returnKeyType: "next",
next: "age"
},
age: {
returnKeyType: "next",
next: "rememberMe"
},
rememberMe: {
returnKeyType: "done",
next: "rememberMe"
}
}
};
Now here is the magic, We just need to loop on the array and dynamically add the focus function for each input
Object.keys(options.fields).forEach(function(key) {
if ("next" in options.fields[key]) {
if (key == options.fields[key]["next"]) {
//set the blur in case the input is the last input
options.fields[key].onSubmitEditing = () => {
self.refs["form"].getComponent(key).refs.input.blur();
};
}else{
//set the next focus option
options.fields[key].onSubmitEditing = () => {
self.refs["form"].getComponent(options.fields[key]["next"]).refs.input.focus();
};
}
}
})
self.setState({
options: options
});
and then in the render section you should be able to pass fields and options
<Form
ref="form"
type={Person}
options={this.state.options}
value={this.state.value}
/>
This should work dynamically, without worry about how much inputs do you have.
Hopefully this answer your question!
// i have a form handler class which does it this is its code.
import React, { Component } from "react";
import { getKeyboardType, isInputValid, isSecureTextEntry } from "./Utils";
class Form extends Component {
childReferences = [];
childDetails = [];
/* go through each input and check validation based on its type */
checkValidation = () => {
let isValid = true;
for (let i = 0; i < this.childReferences.length; i++) {
if (
this.childReferences[i].getValue &&
!isInputValid(
this.childReferences[i].getValue(),
this.childDetails[i].type
)
) {
this.childReferences[i].setError &&
this.childReferences[i].setError(true);
isValid = false;
}
}
return isValid;
};
/* collecting user entered values from all inputs */
getValues = () => {
let data = {};
this.childReferences.forEach((item, index) => {
data[this.childDetails[index].identifier] = item.getValue();
});
return data;
};
onSubmitForm = () => {
return this.checkValidation() ? this.getValues() : undefined;
};
refCollector = ref => this.childReferences.push(ref);
collectChildDetails = childProps =>
this.childDetails.push({
identifier: childProps.identifier,
type: childProps.type
});
/* handling onSubmit of each input when user moves to next input from keyboard */
onSubmitEditing = index => ev => {
if (
index < this.childReferences.length - 1 &&
this.childReferences[index + 1].setFocus
) {
this.childReferences[index + 1].setFocus();
}
};
render() {
const wrappedChildrens = [];
React.Children.map(this.props.children, (child, index) => {
if (!child) {
return;
}
/* holding details of input in an array for later user */
this.collectChildDetails(child.props);
/* cloning children and injecting some new props on them */
wrappedChildrens.push(
React.cloneElement(child, {
key: child.props.identifier || `${child.props.type}_${index}`,
ref: this.refCollector,
onSubmitEditing: this.onSubmitEditing(index),
returnKeyType:
index < this.props.children.length - 1 ? "next" : "done",
keyboardType: getKeyboardType(child.props),
secureTextEntry: isSecureTextEntry(child.props)
})
);
});
return wrappedChildrens;
}
}
export default Form;
// this is how its used
<Form ref={ref => (this.formHandler = ref)}>
<MaterialInput
label="Emial address"
error="Enter a valid email"
rightImage={Images.forwardArrow}
type={INPUT_TYPES.EMAIL}
identifier="email"
blurOnSubmit={false}
/>
<MaterialInput
label="Password"
error="Password length must be greater then six"
rightImage={Images.forwardArrow}
type={INPUT_TYPES.PASSWORD}
identifier="password"
/>
</Form>

React pass all events to child component

Is it possible in React to pass all event to child element.
as an example I've got a custom Button class, that (simplified) looks something like this:
class Button extends Component {
constructor (props) {
super(props);
this.onClick = this.onClick.bind(this);
}
/* .... */
onClick (ev) {
const { disabled, onClick } = this.props;
if (!disabled) {
onClick(ev);
}
}
render () {
const {
children,
disabled,
type
} = this.props;
return (
<button
disabled={disabled}
onClick={this.onClick}
ref="button"
type={type}
>{children}</button>
}
}
I don't know what events i may want to use in the future (onMouseDown, onMouseUp, onBlur, onKeyDown, onTouchStart, and so on...)
Is it possible to pass all possible events to the button element without writing out a prop for every possible event?
adding {...this.props} to the button element is not what I want because it passes all props and some props (like className which is omitted in this example) should not be passed directly.
I thought about cloning the props object and deleting the props which should not be passed directly but this feels like a hack. Does anybody know a cleaner way?
I've written a function to iterate over the props and filter out all properties starting with 'on' this is the closest I've come so far. In case it helps anyone else:
/* helpers.js */
export function filterEvents (props, ignore = []) {
let events = {};
for (let property in props) {
if (props.hasOwnProperty(property)) {
if (property.startsWith('on') && ignore.indexOf(property) === -1) {
events[property] = props[property];
}
}
}
return events;
}
/* Tests for the filterEvents */
import { expect } from 'chai';
import { filterEvents } from './helpers';
describe('filterEvents', () => {
const props = {
className: 'someClass',
disabled: true,
onBlur: 'onBlur',
onClick: 'onClick',
onMouseDown: 'onMouseDown',
onMouseUp: 'onMouseUp'
};
it('only returns keys starting with on', () => {
const expected = {
onBlur: 'onBlur',
onClick: 'onClick',
onMouseDown: 'onMouseDown',
onMouseUp: 'onMouseUp'
};
expect(filterEvents(props)).to.deep.equal(expected);
});
it('only returns keys starting with on minus the ones in the ignore array', () => {
const expected = {
onBlur: 'onBlur',
onMouseUp: 'onMouseUp'
};
const ignore = ['onClick', 'onMouseDown'];
expect(filterEvents(props, ignore)).to.deep.equal(expected);
});
});
/* Using the function inside a component */
import { filterEvents } from './helpers'; //at the top of the components file
//Inside the render method:
const events = filterEvents(this.props, ['onClick']); //don't include onClick it's handled like the questions example
return (
<button
disabled={this.props.disabled}
onClick={this.onClick}
{...events}
>
{this.props.children}
</button>
);
I've taken Barry127's answer and added all the event handlers from React and put them in an object.
const acceptedEventHandlersForComponentValidationFunction = {
clipBoard: [
"onCopy",
"onCut",
"onPaste",
"onCopyCapture",
"onCutCapture",
"onPasteCapture"
],
composition: [
"onCompositionEnd",
"onCompositionStart",
"onCompositionUpdate",
"onCompositionEndCapture",
"onCompositionStartCapture",
"onCompositionUpdateCapture"
],
keyboard: [
"onKeyDown",
"onKeyPress",
"onKeyUp",
"onKeyDownCapture",
"onKeyPressCapture",
"onKeyUpCapture"
],
focus: ["onFocus", "onBlur", "onFocusCapture", "onBlurCapture"],
form: [
"onChange",
"onInput",
"onInvalid",
"onReset",
"onSubmit",
"onChangeCapture",
"onInputCapture",
"onInvalidCapture",
"onResetCapture",
"onSubmitCapture"
],
generic: ["onError", "onLoad", "onErrorCapture", "onLoadCapture"],
mouse: [
"onClick",
"onContextMenu",
"onDoubleClick",
"onDrag",
"onDragEnd",
"onDragEnter",
"onDragExit",
"onDragLeave",
"onDragOver",
"onDragStart",
"onDrop",
"onMouseDown",
"onMouseEnter",
"onMouseLeave",
"onMouseMove",
"onMouseOut",
"onMouseOver",
"onMouseUp",
"onClickCapture",
"onContextMenuCapture",
"onDoubleClickCapture",
"onDragCapture",
"onDragEndCapture",
"onDragEnterCapture",
"onDragExitCapture",
"onDragLeaveCapture",
"onDragOverCapture",
"onDragStartCapture",
"onDropCapture",
"onMouseDownCapture",
"onMouseMoveCapture",
"onMouseOutCapture",
"onMouseOverCapture",
"onMouseUpCapture"
],
pointer: [
"onPointerDown",
"onPointerMove",
"onPointerUp",
"onPointerCancel",
"onGotPointerCapture",
"onLostPointerCapture",
"onPointerEnter",
"onPointerLeave",
"onPointerOver",
"onPointerOut",
"onPointerDownCapture",
"onPointerMoveCapture",
"onPointerUpCapture",
"onPointerCancelCapture",
"onGotPointerCaptureCapture",
"onLostPointerCaptureCapture",
"onPointerOverCapture",
"onPointerOutCapture"
],
selection: ["onSelect", "onSelectCapture"],
touch: [
"onTouchCancel",
"onTouchEnd",
"onTouchMove",
"onTouchStart",
"onTouchCancelCapture",
"onTouchEndCapture",
"onTouchMoveCapture",
"onTouchStartCapture"
],
ui: ["onScroll", "onScrollCapture"],
wheel: ["onWheel", "onWheelCapture"],
media: [
"onAbort",
"onCanPlay",
"onCanPlayThrough",
"onDurationChange",
"onEmptied",
"onEncrypted",
"onEnded",
"onError",
"onLoadedData",
"onLoadedMetadata",
"onLoadStart",
"onPause",
"onPlay",
"onPlaying",
"onProgress",
"onRateChange",
"onSeeked",
"onSeeking",
"onStalled",
"onSuspend",
"onTimeUpdate",
"onVolumeChange",
"onWaiting",
"onAbortCapture",
"onCanPlayCapture",
"onCanPlayThroughCapture",
"onDurationChangeCapture",
"onEmptiedCapture",
"onEncryptedCapture",
"onEndedCapture",
"onErrorCapture",
"onLoadedDataCapture",
"onLoadedMetadataCapture",
"onLoadStartCapture",
"onPauseCapture",
"onPlayCapture",
"onPlayingCapture",
"onProgressCapture",
"onRateChangeCapture",
"onSeekedCapture",
"onSeekingCapture",
"onStalledCapture",
"onSuspendCapture",
"onTimeUpdateCapture",
"onVolumeChangeCapture",
"onWaitingCapture"
],
image: ["onLoad", "onError", "onLoadCapture", "onErrorCapture"],
animation: [
"onAnimationStart",
"onAnimationEnd",
"onAnimationIteration",
"onAnimationStartCapture",
"onAnimationEndCapture",
"onAnimationIterationCapture"
],
transition: ["onTransitionEnd", "onTransitionEndCapture"],
other: ["onToggle", "onToggleCapture"]
}
/*
- Component props event handler vilidation
Return all valid events to be used on a component
{
acceptedEventHandlerTypes: [
"${event handler type}"
],
eventHandlers: {
${event}: "${callback function}" // ${event} can contain "Capture" at the end to register the event handler for the capture phase
}
}
*/
const validateComponentPropsEventHandlers = (
acceptedEventHandlerTypes,
eventHandlers = {}
) => {
if (Object.keys(eventHandlers).length == 0) {
return {}
}
// Fill eventsForSpecifiedType with only the required events
let eventsForSpecifiedType = {}
let eventsCount = 0
for (const eventHandlerType in acceptedEventHandlerTypes) {
if (
acceptedEventHandlerTypes[eventHandlerType] in
acceptedEventHandlersForComponentValidationFunction
) {
const newEvents =
acceptedEventHandlersForComponentValidationFunction[
acceptedEventHandlerTypes[eventHandlerType]
]
eventsForSpecifiedType[
acceptedEventHandlerTypes[eventHandlerType]
] = newEvents
eventsCount += newEvents.length
}
}
// Fill events
let events = {}
let eventsCountCheck = 0
const checkIfEventsCountHasBeenReached = () =>
eventsCountCheck == eventsCount
for (const eventHandler in eventHandlers) {
if (checkIfEventsCountHasBeenReached()) {
return events
}
// Append event handler to events object if it is recognised
Object.values(eventsForSpecifiedType).forEach(
(EVENT_HANDLERS) => {
if (
EVENT_HANDLERS.includes(eventHandler) &&
!(eventHandler in events)
) {
events[eventHandler] = eventHandlers[eventHandler]
eventsCountCheck += 1
}
if (checkIfEventsCountHasBeenReached()) {
return events
}
}
)
}
return events
}
// Usage
const test = () => {console.log("test")}
const events = validateComponentPropsEventHandlers(["mouse"], { onClick: test })
console.log(events)
// <button {...events}>Button</button>

Categories

Resources