creating extensible components.md

We would like to create a flexible set of functions for rendering a view on checkboxes. Many libraries try to take “control” of the rendering process and introduce brittleness into the component by making too many assumptions and then trying to provide too many “options” to allow you to customize the more monolithic comopnent. By breaking apart our functions into smaller ones, while offering defaults with all the bells and whistles, we can make a much more flexible library that can be used in many different scenarios.

You can use ramda and recompose “in the small” assuming you are using it elswhere in your library that justifies its inclusion in your bundle.

Here is a simple example of how we can use these extra tools to create a very simple, non-aria enabled check box list. It is not optimized for large lists either i.e. its not “virtualized.”

import React from "react"
import { defaultProps } from "recompose"
import R from "ramda"
import { firstOrElse, noop, composeEventHandlers, isDOMElement } from "./Utils"

/** 
 * Parent for checkbox list. Only passes enhanced props to and then renders the first child.
 * The only requirement for the child is to receive `getInputProps` and potentially
 * use it to render a checkbox. Children can use `updateList` in getInputProps to contribute 
 * to the overall checkbox list reporting from the mangaer.
 *
 * The underyling "checkbox creator" can optionally use `getInputProps` to set the props
 * for the input element and `updateList` to add and remove checked status
 * back to the manager for reporting in `onListChange`.
 *
 * @param onListChange Function Set => {}. Set contains checked "values"
 *
 */
export class CheckBoxListManager extends React.Component {

    constructor(props) {
        super(props)
        this.state = {
            onListChange: props.onListChange
        }
    }

    static defaultProps = {
        onChange: noop,
        onListChange: noop
    }

    /** Non-state in the sense it does not drive rendering. */
    checkedValues = new Set()

    input_handleOnChange = (e) => {
        this.state.onListChange(this.checkedValues)
    }

    input_updateList = (key, checked) => checked? this.checkedValues.add(key) : this.checkedValues.remove(key)
    
    getInputProps = ({type, onChange, ...rest} = {}) => {
        return {
            role: "checkbox",
            type: "checkbox",
            onChange: composeEventHandlers(onChange, this.input_handleOnChange),
            updateList: this.input_updateList,
            ...rest,
        }
    }

    getCombinedProps = () => ({
            getInputProps: this.getInputProps
        })

    render() {
        this.checkedValues.clear()
        const children = firstOrElse(this.props.children, noop)
        const element = firstOrElse(children(this.getCombinedProps()))
        if(!element) return null
        return element
    }
}

const isNilOr = (v, o) => R.isNil(v) ? o : v;

/** 
 * Default and fairly standard checkbox list implementation. Renders a list of checkboxes from an options list (a strict value).
 * The outer container does not need to know about ChcekBoxListManager. The callback onChange receives
 * convenient properties such as the option item and its checked status as well as the event object. This
 * class takes over the rendering process and makes assumptions about the options data model.
 *
 * @param options Array of {value,label} pairs. Value will be converted to a string.
 * @param checked Array|function of values representing checked status or a function taking a value and returning
 *   boolean (true if checked) . If checked is undefined it will look for "checked" property on each option element.
 * @param {(value, checked, evt) => undefined } onChange Optional. Individual checkbox change callback. You can use 
 *   onListChange instead of this.
 * @parma {(Set of values) => undefined} onListChange Called with all checked values if a checkbox changes or after the
 *   the component mounts.
 * @param Container Outer react container Component.
 * @param CheckBoxComponent Your own checkbox component. It will be passed {key,value,label,checked,getInputProps}.
 */
class CheckBoxList extends React.Component {

    // make map func an arrow outside of render so the callback func does not force a render
    makeItem = (opt, idx, checkme, CheckBoxComponent, getInputProps, firstOnChange) => {
        const value = isNilOr(opt.value, `value-${idx}`)
        const label = isNilOr(opt.label, `label-${idx}`)
        const isChecked = checkme(value)
        const cprops = {
            key: value.toString(),
            label: label,
            checked: isChecked,
            value: value,
            // the only trick we do is to make the onChange API easier to use.
            // so we change the onChange handler from getInputProps().
            getInputProps: ({onChange, ...p}) => getInputProps({
                ...p,
                onChange: composeEventHandlers(firstOnChange(value, isChecked), onChange)})}
        //console.log(cprops)
        return(<CheckBoxComponent {...cprops} />)
    }
    
    render() {
        const {options, checked, onListChange, onChange, CheckBoxComponent, Container, ...rest} = this.props
        const originalOnChange = onChange
        // When rendering, we already know what is checked or not, so curry onChange handler with that info.
        const localOnChange = (value, isChecked, onOneChanged) => onOneChanged ?
                                                                R.curry(onOneChanged)(value, !isChecked) : null
        const checkme = (value) => {
            if(R.is(Array, checked)) return checked.includes(value);
            else if(R.is(Function, checked)) return checked(value);
            return false;
        }
        
        return(
            <CheckBoxListManager onChange={onChange} onListChange={onListChange}> 
                { ({getInputProps}) => (
                    <Container {...rest} >
                        { options.map((opt, idx) => this.makeItem(opt, idx,
                                                                  checkme,
                                                                  CheckBoxComponent,
                                                                  getInputProps,
                                                                  R.curry(localOnChange)(R.__, R.__, originalOnChange)))}
                    </Container>
                )}
            </CheckBoxListManager>
        )
    }
}

/** 
 * A checkbox input element wrapped with a label.
 */
export function CheckBox({getInputProps, ...rest}) {
    const { key, value, label, checked, updateList, ...iprops } = getInputProps(rest)
    const id  = value.toString()
    if(checked) updateList(value, checked)
    return(
        <label htmlFor={id}>
            <input id={id}
                   name={id}
                   checked={checked}
                   {...iprops}/>
            {label}
        </label>
    )
}

/** Render items using a flexbox.
 * @param direction "column"|"row", default is column.
 */
export function DefaultContainer(props) {
    const { direction, children } = props;
    return(
        <div style={{display:"flex", flexDirection: isNilOr(direction,"column")}}>
            {children}
        </div>
    );
}

export default defaultProps({ options: [], checked:[],
                              Container: DefaultContainer,
                              CheckBoxComponent: CheckBox})(CheckBoxList)

You should note that instead of trying to pass in some type of hiearchy of class names or styles to style the “generated” children we just try to make it easy to create your own children and style them the way you choose. In general, you can use CheckboxListManager directly. CheckboxList was created as a convenience but makes many assumptions about the data model but also adds a convenience callback signature passing in the “value” that was changed and what that change was. CheckboxList is also an example of how to use the CheckboxListManager component.

Generally, you should provide a bare bones model that can be successively augmented with additional functionality so that your users can adapt the components to their needs more easily. That’s our “Manager” class above. Manager maintains some non-render related state. The checkedValues is a set of what is checked/not-checked when the render was performed but it does not drive the render e.g. changing checkedValues does not force a render. Instead, it is used to report aggregated information when an “change” occurs–that’s it. Obviously, if you use redux or some other state manager, the list of checks can be found.

The Manager is implemented using an “injected props getter” that sets a few basic props on an input element–hence the name getInputProps. This technique allows your clients to render a tree they prefer versus being forced to use components that you provide. The burden is placed on them to use the injected functions properly of course.

It’s very popular to add a Promise as the data source for a component. The component above takes a strict value (which means its not a Promise). If you want to add a Promise data source, it makes more sense to use recompose (or something else) and compose with the component above so that when there is a data changes or the Promise completes, a render occurs. There is a good example in the https://facebook.github.io/react/docs/higher-order-components.html. Of course, a redux like state manager will do this for you.

There is no “best” answer on how to decompose your UI but hopefully the above gives you ideas.

Note: A few utilities were stolen from around the web including downselect:

/** Do nothing. */
export function noop() {}

/** 
 * If arg is an array, return the first element if it exists,
 * otherwise, return other.
 */
export function firstOrElse(arg, other) {
    arg = Array.isArray(arg) ? arg[0] : arg
    if(!arg && other) return other;
    else return arg;
}

/** 
 * Execute fns with the same args in order until 
 * `event.preventDefault()` is called. This is really
 * just a takeWhile and map where "event" is mutable state.
 */
export function composeEventHandlers(...fns) {
    return (event, ...args) => {
        fns.some(fn => { // does this test in array order???
            fn && fn(event, ...args)
            return event.defaultPrevented
        })
    }
}

Comments

Popular posts from this blog

quick note on scala.js, react hooks, monix, auth

zio environment and modules pattern: zio, scala.js, react, query management

user experience, scala.js, cats-effect, IO