Unit 5: Custom Components

This may be one of the most important units. After completion, you will be able to create your own reusable components and share them with the rest of the world. Throughout this unit, we will create a Password Validator component to illustrate the different steps to component creation. As input, you can select the checks to be performed to the password, such as: have at least one number, capital letter, symbol, etc. The validations are performed with regular expressions (regex):

const checksConfig = {
minCharacters: {regex: /.{8,}/, text: 'Be at least 8 characters'},
numbers: {regex: /\d+/, text: 'At least one number'},
letters: {regex: /[a-z]/, text: 'At least one letter'},
capitalLetters: {regex: /[A-Z]/, text: 'At least one capital letter'},
symbols: {regex: /[!-/:-@[-`{-~]/, text: 'At least one special character'},
...config
}

After the validations, the component displays the requirement or checks that have not yet been met. See the expected final result below:

Definition

So far, in previous units we have showed you predefined components, but it is time to learn how to create your own. We have already established that components are functions. These functions take properties as arguments and return a component structure. Depending on the type of component you want to build, besides properties it can also require structure as input. In the example below, the Greeting custom component takes the name property and displays a greeting message to the selected name:

import {app} from '@onejs-dev/core';
import {View, Text} from '@onejs-dev/components';

/* Custom component WITHOUT structure input */
const Greeting = ({name}={}) => {
return View()(Text()(`Hello ${name}!`));
}

const App = () => Greeting({name: 'oneJS'});

app({component: App, theme: {preset: 'oneJS'}});

Depending on the type of component you want to build, besides properties it can also require structure as input. For convenience, instead of creating another property for the structure, we place this property as the input for a nested function. This leads to cleaner and more legible code when the component is instantiated. Take the ColorCard example below:

import {app} from '@onejs-dev/core';
import {View, Text} from '@onejs-dev/components';

/* Custom component WITH structure input */
const ColorCard = ({color}={}) => structure => {
    return View({style: {backgroundColor: color, padding: 10}})(structure);
}

const App = () => ColorCard({color: 'pink'})(Text()('Card Text'));

app({component: App, theme: {preset: 'oneJS'}});

The component functions above are valid, but we can still optimize them. Use the Component function providing: 1. a unique name, 2. a Boolean value indicating whether it requires structure input or not, and 3. your already designed component. This has several optimization advantages as it registers your component function as a React or React Native element, allowing you to use hooks inside of it.

/* Wrapped in 'Component' function
const Greeting = Component('Greeting', false, ({name}={}) => {
    return View()(Text()(`Hello ${name}!`));
});
const ColorCard = Component('ColorCard', true, ({color}={}) => structure => {
    return View({style: {backgroundColor: color, padding: 10}})(structure);
});

Let's see this concepts applied to our PasswordValidator component:

const PasswordValidator = Component('Password', false, ({password, checks, config={}}={}) => {
    const checksConfig = {
        minCharacters: {regex: /.{8,}/, text: 'Be at least 8 characters'},
        numbers: {regex: /\d+/, text: 'At least one number'},
        letters: {regex: /[a-z]/, text: 'At least one letter'},
        capitalLetters: {regex: /[A-Z]/, text: 'At least one capital letter'},
        symbols: {regex: /[!-/:-@[-`{-~]/, text: 'At least one special character'},
        ...config
    }

    password = password ?? '';
    const failedChecks = checks.filter(check => !password.match(checksConfig[check].regex));
    const isValid = failedChecks.length === 0;  

    return View({content: {direction: 'column'}})([
        Text()('Requirements:'),
        failedChecks.map(check => Text({list: 'bullets'})(checksConfig[check].text))
    ]);
});

Attributes

When designing a component, the best practice is to explicitly name the properties that impact the output of your component, but at the same time, it is useful to have the flexibility to pass any number of standard attributes to your component. You can achieve this by providing the attributes as a rest parameter. If you feed the attributes to your component’s structure, you can automatically inherit any number of properties, such as class, id, style, etc. You can see below how the PasswordValidator is updated to incorporate this:

const PasswordValidator = Component('Password', false, ({password, checks, config={}, ...attributes}={}) => {
    const checksConfig = {
        minCharacters: {regex: /.{8,}/, text: 'Be at least 8 characters'},
        numbers: {regex: /\d+/, text: 'At least one number'},
        letters: {regex: /[a-z]/, text: 'At least one letter'},
        capitalLetters: {regex: /[A-Z]/, text: 'At least one capital letter'},
        symbols: {regex: /[!-/:-@[-`{-~]/, text: 'At least one special character'},
        ...config
    }

    password = password ?? '';
    const failedChecks = checks.filter(check => !password.match(checksConfig[check].regex));
    const isValid = failedChecks.length === 0;  

    return View({content: {direction: 'column'}, ...attributes})([
        Text()('Requirements:'),
        failedChecks.map(check => Text({list: 'bullets'})(checksConfig[check].text))
    ]);
});

Flavor

The best way to style a custom component is by leveraging the power of flavors. These allow you to create components that will inherit the look and feel of your app or any app they are placed in. Just provide a flavor property and read its variables to customize the style.

import {app, Component, readFlavor} from '@onejs-dev/core';
import {View, Text} from '@onejs-dev/components';

const Greeting = Component('Greeting', false, ({name, flavor, ...attributes}={}) => {
    const viewStyle = {
        padding: 10,
        borderRadius: flavor?.radius ?? 0,
        backgroundColor: flavor?.backgroundColor ?? 'transparent'
    };
    const textStyle = {
        color: flavor?.textColor ?? 'black'
    };
    return View({style: viewStyle, ...attributes})(Text({style: textStyle})(`Hello ${name}!`));
});

const App = () => View({content: {direction: 'column'}})([
    Greeting({name: 'oneJS'}),
    Greeting({name: 'oneJS', flavor: readFlavor('light', 'primaryBackground')}),
    Greeting({name: 'oneJS', flavor: readFlavor('light', 'rejectBackground')})
]);

app({component: App, theme: {preset: 'oneJS'}});

As you can see, now the component can be easily customized respecting the look and feel of the app. If you want, you can provide a default value to the flavor property, so that you don’t need to explicitly provide a flavor every time the component function is called. You can see how this is implemented in the PasswordValidator example:

const PasswordValidator = Component('Password', false, ({password, checks, config={}, 
    flavor=readFlavor('default'), ...attributes}={}) => { //By default, use the 'default' flavor
    const checksConfig = {
        minCharacters: {regex: /.{8,}/, text: 'Be at least 8 characters'},
        numbers: {regex: /\d+/, text: 'At least one number'},
        letters: {regex: /[a-z]/, text: 'At least one letter'},
        capitalLetters: {regex: /[A-Z]/, text: 'At least one capital letter'},
        symbols: {regex: /[!-/:-@[-`{-~]/, text: 'At least one special character'},
        ...config
    }

    password = password ?? '';
    const failedChecks = checks.filter(check => !password.match(checksConfig[check].regex));
    const isValid = failedChecks.length === 0;  

    /* Create style using flavor */
    const flavorStyle = {
        backgroundColor: 'white',
        borderRadius: flavor?.radius, 
        borderColor: flavor?.borderColor,
        borderStyle: flavor?.borderStyle,
        borderWidth: flavor?.borderWidth,
        padding: 15
    };
    
    /* Apply the flavor style to the view */
    return View({content: {direction: 'column'}, style: flavorStyle, ...attributes})([
        Text({style: {marginBottom: '5px'}})('Requirements:'),
        failedChecks.map(check => Text({list: 'bullets'})(checksConfig[check].text))
    ]);
});

Style

Even if you expose the customization of your component through flavors, you can have even more control by leveraging the style attribute. In the previous section, we have created styles based on the flavor properties. To enable further customization trough the style attribute, we need to merge both the flavor’s and the attribute’s styles. oneJS provides the mergeStyles function to this end; input the styles you want to merge in increasing priority and you will get the merged style as a result.

import {app, Component, readFlavor, mergeStyles} from '@onejs-dev/core';
import {View, Text} from '@onejs-dev/components';

const Greeting = Component('Greeting', false, ({name, flavor, ...attributes}={}) => {
    const viewStyle = {
        padding: 10,
        borderRadius: flavor?.radius ?? 0,
        backgroundColor: flavor?.backgroundColor ?? 'transparent'
    };
    /* Styles are merged: attributes['style'] has priority over viewStyle */
    attributes['style'] = mergeStyles(viewStyle, attributes['style']);
    /* You can also explicitly describe the style priority as displayed below */
    const textStyle = {
        color: attributes?.style?.color ?? flavor?.textColor ?? 'black'
    };
    return View(attributes)(Text({style: textStyle})(`Hello ${name}!`));
});

const App = () => View({content: {direction: 'column'}})([
    Greeting({name: 'oneJS', style: {backgroundColor: 'pink'}}),
    Greeting({name: 'oneJS', style: {color: 'black'}, flavor: readFlavor('light', 'primaryBackground')}),
    Greeting({name: 'oneJS', flavor: readFlavor('light', 'rejectBackground')})
]);

app({component: App, theme: {preset: 'oneJS'}});

As you probably already expect, let's apply these learnings to the PasswordValidator component:

const PasswordValidator = Component('Password', false, ({password, checks, config={}, 
    flavor=readFlavor('default'), ...attributes}={}) => {
    const checksConfig = {
        minCharacters: {regex: /.{8,}/, text: 'Be at least 8 characters'},
        numbers: {regex: /\d+/, text: 'At least one number'},
        letters: {regex: /[a-z]/, text: 'At least one letter'},
        capitalLetters: {regex: /[A-Z]/, text: 'At least one capital letter'},
        symbols: {regex: /[!-/:-@[-`{-~]/, text: 'At least one special character'},
        ...config
    }

    password = password ?? '';
    const failedChecks = checks.filter(check => !password.match(checksConfig[check].regex));
    const isValid = failedChecks.length === 0;  

    const flavorStyle = {
        backgroundColor: 'white',
        borderRadius: flavor?.radius, 
        borderColor: flavor?.borderColor,
        borderStyle: flavor?.borderStyle,
        borderWidth: flavor?.borderWidth,
        padding: 15
    };

    /* Merge the external style with the internal flavor style  */
    attributes['style'] = mergeStyles(flavorStyle, attributes['style']);
    
    /* 'attributes' already contains the style attribute */
    return View({content: {direction: 'column'}, ...attributes})([
        Text({style: {marginBottom: '5px'}})('Requirements:'),
        failedChecks.map(check => Text({list: 'bullets'})(checksConfig[check].text))
    ]);
});

State

The final step to make a custom component interactive is giving it the ability to read and update the state of the app. As we have already established, a component should never directly update a state variable, instead, provide a property that takes a function. This function is then called by your component when a certain event occurs. For inputs, we name this function onChange as it is called when the value of the input changes. The user of this custom component can then decide if they want to provide a function to update the state or not. See the example below where the onChange function in the custom Multiplier component is used to update the number state variable:

import {app, Component, read, update} from '@onejs-dev/core';
import {View, Input, Text} from '@onejs-dev/components';

const state = {number: 1};

/* Multiplies the input number times the pressed button */
const Multiplier = Component('Multiplier', false, ({value, onChange}={}) => {
    const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9];
    return View()(numbers.map(number => Input({
        type: 'button', title: number, 
        onClick: () => {onChange(value * number)}
    })));
});

const App = () => View({content: {direction: 'column'}})([
    Text()('Result: ' + read('number')),
    Multiplier({value: read('number'), onChange: update('number')}),
]);

app({component: App, state: state, theme: {preset: 'oneJS'}});

With these notions we can finally complete the PasswordValidator component we have been using as an example:

import {app, read, update, Component, readFlavor, mergeStyles} from '@onejs-dev/core';
import {View, Input, Text} from '@onejs-dev/components';

/* State Configuration */
const state = {
    password: '',
    isValid: false
}; 

const PasswordValidator = Component('Password', false, ({password, checks, config={}, 
    value, onChange=()=>{}, flavor=readFlavor('default'), ...attributes}={}) => {
    const checksConfig = {
        minCharacters: {regex: /.{8,}/, text: 'Be at least 8 characters'},
        numbers: {regex: /\d+/, text: 'At least one number'},
        letters: {regex: /[a-z]/, text: 'At least one letter'},
        capitalLetters: {regex: /[A-Z]/, text: 'At least one capital letter'},
        symbols: {regex: /[!-/:-@[-`{-~]/, text: 'At least one special character'},
        ...config
    }

    password = password ?? '';
    const failedChecks = checks.filter(check => !password.match(checksConfig[check].regex));
    const isValid = failedChecks.length === 0;  
    /* Trigger onChange function */
    if(value !== isValid) onChange(isValid);

    const flavorStyle = {
        backgroundColor: 'white',
        borderRadius: flavor?.radius, 
        borderColor: flavor?.borderColor,
        borderStyle: flavor?.borderStyle,
        borderWidth: flavor?.borderWidth,
        padding: 15
    };
    attributes['style'] = mergeStyles(flavorStyle, attributes['style']);
    
    return View({content: {direction: 'column'}, ...attributes})([
        Text({style: {marginBottom: '5px'}})('Requirements:'),
        failedChecks.map(check => Text({list: 'bullets'})(checksConfig[check].text))
    ]);
});

const App = () => {
    return  View({content: {direction: 'column', gap: 10}})([
        Input({type: 'password', value: read('password'), onChange: update('password'), placeholder: 'Password'}),
        /* Bind to state variables */
        PasswordValidator({
            password: read('password'), checks: ['minCharacters', 'numbers', 'letters'],
            value: read('isValid'), onChange: update('isValid')
        }),
        View({visible: read('isValid')})(Input({type: 'button', title: 'Strong password', flavor: readFlavor('accept')}))
    ]);
};

/* App Function: Renders the App Component in the screen */
app({component: App, state: state, theme: {preset: 'oneJS'}});

You are now prepared to create your own reusable components and start contributing to the oneJS community. We are excited to see what you are capable of creating. You have now surpassed the equator of our units. In the next unit you will learn how to make your app navigable.