Coder Social home page Coder Social logo

factor's Introduction

Factor

Factor is a light-weight library for building functionally-styled, reactive web components.

Installation

The library can be installed using NPM.

npm install --save @potient/factor

It can then be imported as an es6 module.

import * as Factor from '@potient/factor'

Usage

Factor can be used to define a custom element.

<my-greeter name="Everyone"></my-greeter>
<script type="module">
    import { define } from '@potient/factor/Factor.js'
    const MyGreeter = define('MyGreeter', {
        props: {
            name: { type: String, default: 'World' },
        },
        template: '<p>Hello, {{name}}!</p>',
    })
</script>

Features

  1. Declarative, reactive template binding without virtual DOM overhead

  2. CSS animations for element entries and exits

  3. One-way data flow within individual components via actions and transforms

  4. Calculated properties with recursion protection

Quick Feature Tutorial

This section provides a quick overview of how to use the various features of this library.

Templates

Factor does not have a parser. Instead, it sets a template string as the HTML content of a <template> element, and walks the tree to bind the template to the custom element’s view data. As such, a template must be valid HTML in order to be processed.

Text Interpolation

Factor supports text interpolation using double-curly braces.

<p>Hello {{person.name}}</p>

This also works with attribute values.

<div id="person-{{person.id}}"></div>

However, this does not work with element tag names, since curly braces are not valid in tag names.

Tag Directives

Tag directives are html tags that are processed specially by Factor. Currently, Factor comes with three built-in tag directives, <if> and <unless> for conditional rendering and <for> for rendering collections of items.

<if condition="showThis">
    <p>Showing</p>
</if>

In the above example, if showThis resolves to a truthy value, the paragraph will be displayed, otherwise it will not. <unless> works identically to if except that it will only render its contents if the condition resolves to a falsey value.

<for values="people" as="person" key-path="id">
    <my-person #data="person"></my-person>
</for>

The key-path is a path into each object in the collection. It is not required, but strongly recommended since it allows the directive to detect reordering of the data. It is also possible to specify a key-function which is called for each item to get the item’s key. If neither a key-function` nor a key-path is provided, the key (e.g. array index) of the item within the collection is used. The key must be unique, or strange things may happen.

Each of these tag directives also support entry and exit animation which is explained in a later section.

Additional tag directives may be registered using the exported Template.registerTagDirective function.

import {Template} from '@potient/factor/Factor.js'
Template.registerTagDirective({
    tag: 'mydirective',
    bind(element) {
        // Process element
        return function (data) {
            // Update the view
        }
    }
})

There are certain situations where using a directive tag will not work as expected, such as when iterating within a table element. For such cases, or when there is only a single element to apply the directive to, you can use the directive attribute instead.

<table>
    <tbody>
        <tr directive="for" values="rows" as="row" key-path="id" id="row-{{row.id}}">
            <td>{{row.id}}</td>
            <td>{{row.name}}</td>
        </tr>
    </tbody>
</table>

Attribute Directives

Special attributes may be used with the template to set various values of the element. Factor comes with six built-in attribute directives: attr for setting attributes, class for updating classes, id for setting the id property, on for setting event listeners, prop for setting properties, and style for modifying the element’s styles.

Attribute directives are used as a prefix followed by a colon, or as a symbol prefix as a shorthand. For example, the prop directive may be used as prop:someKey="someValueKey" or #someKey="someValueKey".

Most of the built-in directives support passing in an empty key which alters the directive’s behavior to expect an object of values rather than a single value. For example, you can set an object of properties on an element with the prop directive.

<div prop:="properties"></div>
<!-- Equivalently -->
<div :="properties"></div>
Attr

The attr directive binds an elements attribute to the view data.

<a attr:href="theLink">Some Link<a>

The @ prefix is also supported as a shorthand.

<a @href="theLink">Some Link<a>

The value of an attribute will always be converted to a string by the DOM. However, if the value resolves to false, null or undefined, the attribute will be removed. Conversely the value true will set the attributes value to an empty string. This is useful where only the presence or absence of an attribute matters, such as the disabled attribute of <input> elements.

An object of attributes can be provided by omitting the attribute key.

Class

The class directive binds an element’s class to data. A truthy value results in the class being included, whereas a falsey will remove it.

<style>
.capitalize {
    text-transform: uppercase;
}
</style>
<p class:capitalize="doCaps">Some Text</p>

The . symbol can also be used.

<p .capitalize="doCaps">Some Text</p>

If no class name is provided, an object of class names is expected. The keys of the object are the class names, and each key with a truthy value is included in the element’s class list.

<p class:="classes">Some Text</p>
<!-- or -->
<p .="classes">Some Text</p>
Id

The id attribute directive can be used to set an id for an element. It can resolve to a string or an array. If an array is provided, the id will be joined with the - character.

<div id:="idProp"></div>

The # symbol can be used as a prefix instead.

<div #="idProp"></div>

If an attribute name is provided, it will be treated as a prefix for the id.

<!-- The id will be something like item-12 -->
<div id:item="itemId"></div>
<!-- Equivalently -->
<div #item="itemId"></div>
On

The on directive sets (and removes) event listeners.

<p>{{clickCount}}</p>
<button on:click="incrementClickCount">Click Me</button>

The ! prefix can be used instead.

<button !click="incrementClickCount">Click Me</button>

The preferred method for creating handlers is with handlers option when defining an element. The advantage of doing this is that the custom element will be passed as the second argument to the function rather than just the event.

const MyClicker = define('MyClicker', {
    handlers: {
        clickHandler(event, myClickerElement) {
            myClickerElement.action('clicked', {})
        },
    },
    template: '<button !click="clickHandler">Click me!</button>',
})

There are convenience methods for creating handlers that automatically trigger a transform or action.

import {define, eventToTransform, eventToAction} from '/path/to/Factor.js'

const MyElement = define('MyElement', {
    handlers: {
        someHandler: eventToTransform('someTransform', (event) => {key: event.someData}),
        otherHandler: eventToAction('someAction', (event) => {key: event.someData}),
    },
    transforms: {
        someTransform() {
            // Do something
        },
    },
    actions: {
        async someAction() {
            // Do something
        },
    },
})

If no event name is provided, an object is expected where the properties are the event names and the values are the handlers.

<a !="events">Link Text</a>
Prop

The prop directive binds an element’s property value.

<my-element prop:some-prop="propValue"></my-element>

Notice that the property name is in kebab-case. This is converted camelCase before the property is set. The reason for this is that attribute names are case insensitive. So prop:some-prop will set the property someProp rather than the property some-prop.

The : symbol prefix may be used instead.

<my-element :some-prop="someValue"></my-element>

If no property name is provided, an object of properties is expected.

<my-element :="properties"></my-element>

The primary advantage of using properties over attributes is that properties are not required to be string values, whereas attributes are.

Style

The style directive sets style values for an element.

<div style:background-color="red"></div>

The $ symbol prefix can be used instead.

<div $background-color="red"></div>

If no style name is provided, an object is expected where the keys are the style names and the values are the style values. When used in this way, the object properties may be the camelCase style name as they are accessed on someElement.styles rather than the kebab-case name.

Registering Attribute Directives

Additional attribute directives may be registered.

import {Template} from '@potient/factor/Factor.js'
Template.registerAttributeDirective({
    prefix: 'data',
    symbol: '%',
    bind(element, key, valueKey) {
        return function setData(data) {
            // Example implementation...not a good one
            const value = getPath(data, valueKey)
            element.dataset[key] = value
        }
    },
})

The symbol is optional and may be any combination of the characters ~!@#$%^&*?.|.

Props

Factor supports defining props for your elements. Properties have a name, a type, a default value, and can be set externally as a property or an attribute. An update to a prop will automatically trigger an update to the elements view.

const MyCounter = Factor.define('MyCounter', {
    props: {
        count: {
            type: Number,
        },
        step: {
            type: Number,
            default: 1,
        },
    },
    handlers: {
        clickHandler: Factor.eventToTransform(),
    },
    transforms: {
        click(state) {
            return {
                ...state,
                count: state.count + state.step,
            }
        },
    },
    template: `
        <button on:click="clickHandler">Clicked {{count}} times.</button>
    `
})

const myCounterEl = document.createElement('my-counter')
myCounterEl.count = 2
myCounterEl.setAttribute('step', '3')

assert(myCounterEl.count === 2)
assert(myCounterEl.step === 3)

When the property’s value is set it will be automatically converted based on the type property. Alternatively, a custom convert function may be supplied. Additionally, the type defines the default value if none is supplied. If no type is provided, no conversion is performed and the default is undefined. Currently, String, Boolean, Number, Array, Object, and Date are supported types.

For the most part conversion works as one might expect. However, setting a Boolean attribute works differently that setting a Boolean property. Any value, including the empty string, is considered a true value when setting a prop with an attribute, whereas setting a boolean prop as a property converts it according to JavaScript’s truthiness rules.

Array and Object properties may define a sub prop to automatically process items within the collection.

By default the corresponding attribute name is calculated from the prop name. For example the prop myKey can be set with the attribute my-key. This is due to case-insensitive natrue of DOM attributes.

Property changes can automatically trigger transforms and actions. The property value will be supplied as the data for the transform or action function.

It is important to note that if setting a prop only triggers a view update if the new value is different than the existing value.

State

Factor elements implement a one-way data flow model for updates. In other words, the element’s data cannot be updated directly, but should instead rely upon transformative functions that return new data states. While this is not enforced (for reasons of efficiency), directly modifying an element’s state will not result in the view being updated and may result in unexpected behavior.

Factor provides two mechanisms for transforming an element’s state: transforms and actions. A transform is a synchronous function that receives the current state along with some data, and returns a new state for the element. An action is an asynchronous function that can perform one or more things (e.g. making an HTTP request to load data) that update the state (typically by triggering transforms).

const MyUser = Factor.define('MyUser', {
    props: {
        user: {type: Object},
        lading: {type: O}
    },
    template: `
        <unless condition="loading">
            <p>{{user.name}}</p>
            <a on:click="refreshUser">Refresh</a>
        </unless>
        <if condition="loading">
            <p>loading</p>
        </if>
    `,
    handlers: {
        refreshUser: Factor.eventToAction('loadUser')
    },
    transforms: {
        setUser(state, user) {
            return {
                ...state,
                user,
                loading: false,
            }
        },
        setLoading(state, loading = true) {
            return {
                ...state,
                loading,
            }
        },
    },
    actions: {
        async init(state, data, ctx) {
            // Load the user on entry
            return ctx.action('loadUser')
        },
        async loadUser(state, data, ctx) {
            // ctx is the element

            if (state.loading) {
                return
            }

            ctx.transform('setLoading')
            const response = await fetch('/path/to/get/user')
            const data = await response.json()
            ctx.transform('setUser', data)
        },
    },
})

Animations

The for, if, and unless tag directives support CSS animations. However, the API is currently subject to change and so is not yet documented.

Styles

Styles can be defined for your element. Styles are shared efficiently across multiple instances of your custom element type. When available, constructable stylesheets are used. Otherwise, the styles are converted to a an object URL using a blob so that the browser only needs to parse the stylesheet once.

const MyParagraph = FactorElement.define('MyParagraph', {
    template: '<p>{{content}}</p>',
    styles: 'p {color: red}',
})

Styles are scoped to the current element, which is why using the p selector in the above example is safe. Styles are also static, meaning they do not support text interpolation.

Styles may also be a URL string, a relative or absolute path, or a URL object and the stylesheet will be loaded from a remote resource. When doing this, it may be valuable to use the import.meta.url value to reference the stylesheet, since you may not know where the file will be loaded from.

const MyParagraph = FactorElement.define('MyParagraph', {
    template: '<p>{{content}}</p>',
    styles: new URL('../styles/my-paragraph.css', import.meta.url),
})

Mixins

If you are creating several different components that share a common structure, mixins maybe useful to avoid repeating code. A mixin is an object that defines props, calculations, handlers, transforms, actions, styles, and a template to be set on the element.

const InputMixin = (type) => ({
  props: {
    name: {type: String},
    placeholder: {type: String},
  },
  template: `<input type="${type}" @name="name" @placeholder="placeholder">`
})

const EmailInput = define('EmailInput', {
  mixins: [InputMixin('email')]
  props: {
    placeholder: {type: String, default: 'Enter an email address'}
  }
})

Contributing

If you would like to contribute, pull requests are welcome.

factor's People

Contributors

mwardle avatar wardlem avatar

Watchers

 avatar  avatar

factor's Issues

Mixins

Components should be composable with mixins.

import InputMixin from '../mixins/InputMixin'
const TextInput = define('TextInput', {
  mixins: [InputMixin({type: 'text'})]
})

An open question is how templates should be handled when it comes to mixins. The easiest solution is to make templates non-inheritable through mixins. Another simple solution would be to inherit the last template in the mixin list. A more complex solution would be to allow templates to be composed in some way through a templating syntax or template pre-processor. The last solution seems to violate the principle of YAGNI. The second solution seems the most reasonable, since it can be easily overwritten by manually setting the template in the define options.

Multiple stylesheets

Multiple stylesheets should be allowed for an element. Additionally, they should be loadable from a remote resource. Furthermore, they should be able to be turned off and on.

const MyElement = define('MyElement', {
  props: {
    color: { type: String },
    text: { type: String }
   },
  styles: [
    new URL('/some/remote/styles.css'),
    'p {font-weight: bold}',
    {
       content: 'p {color: blue}',
       condition: ({color}) => color === 'blue' 
    }
  ],
  template: '<p>{{text}}</p>'
})

Dynamic tag names for elements in templates

Factor should support dynamic tag names in template elements. A special templating syntax needs to be added to tag names for this. Because of the limited set of allowed characters in tag names, the most likely candidate for doing this is a : prefix.

define('MyElement', {
  props: {
    childTag: {
      type: String,
      default: 'div'
    }
  },
  template: '<:childTag></:childTag>'
})

Perhaps more in-line with tag and attribute directives would be to allow attribute prefix directives to be defined.

As yet another alternative, a new built-in tag directive could be defined:

define('MyElement', {
  props: {
    childTag: {
      type: String,
      default: 'div'
    }
  },
  template: '<dynamic tag="childTag"></dynamic>'
})

This may be the best option since it does not require any updates to the parser, which could potentially add overhead to the parsing process.

Enumerated Property Type

Sometimes there is a limited set of permissable values for a property. There should be a property type to handle these situations.

Tag directives do not work in tables

Because of the way that HTML is parsed by the browser, tag directives do not work in tables because the tag is not valid inside a table. An alternative to tag directives is needed, such as declaring a directive as an attribute.

// This doesn't work
<table>
  <tbody>
    <for items="rows" as="row">
      <tr><td>{{row.name}}</td></tr>
    </for>
  </tbody>
</table>
// This would work
<table>
  <tbody>
    <tr directive="for" items="rows" as="row">
      <td>{{row.name}}</td>
    </tr>
  </tbody>
</table>

URL stylesheets

Stylesheets should be able to point to a remote resource. If a stylesheet looks like a URL or path (which is not valid CSS, so there should not be a conflict), or if a stylesheet is an instance of URL, the stylesheet should be loaded from a remote resource.

Stylesheets

Custom elements should use shared stylesheets. Where constructable stylesheets are available, they should be used. Otherwise, they should be mimicked as efficiently as possible.

Add a static `toString()` method for components that returns the tag name.

It would often be more convenient to use the element name directly in your template rather than using the tag name. Because of the way Factor's views are parsed and rendered, this isn't really feasible. As a compromise, being able to use the type in your template would using template literals would be nice. This can already be done by referencing the MyElement.type variable:

import OtherElement from './OtherElement.mjs'

const MyElement = define('MyElement', {
  template: `<${OtherElement.tag}></${OtherElement.tag}>`
})

This proposal would allow you to drop the .tag:

import OtherElement from './OtherElement.mjs'

const MyElement = define('MyElement', {
  template: `<${OtherElement}></${OtherElement}>`
})

You can still access the default behavior by using Function.prototype.toString.call(MyElement) if it is ever needed.

Event helpers options

The eventToTransform and eventToAction helpers need options to automatically stopPropagation and preventDefault.

Change the prop tag directive prefix, add id tag directive

Instead of using # for props, use it for an id attribute directive. Use just : for props. Since . is used for the class directive (taken from CSS), # should also be used for the id directive.

The primary reason an id directive has not yet been added is that the id name hasn't really made sense. However, it might make sense as a prefix.

const template = `<div #item="itemNumber"></div>`
// the id will be set to something to `item-12` if itemNumber resolves to 12

Having no id name will just return the value. An array id will be joined with the - character.

The property syntax will now look like:

const template = `<div :someProp="valueKey"></div>`

Opt-out of rendering system (Imperative components)

Some components do not require Factor's rendering system. The define function should expose a reactive option that turns the rendering system off for the component when set to false. The option should default to true. When the rendering system is off and a template is supplied, the template HTML will be still be inserted into the component's root (usually shadow root) node as a one-time action.

Need a mechanism for easily passing data to components in html

It is currently difficult to send data to top-level components. For example, to set the items in a theoretical list component, one would have to do something like this:

<my-list></my-list>
<script>
  const myList = document.querySelector('my-list')
  myList.items = [
    // Long list of items
  ]
</script>

An attribute combined with an Object prop can be used, but it becomes unwieldy for large objects since spaces are not allowed in the attribute value.

A more convenient way of doing this would be something like this:

<my-list>
  <script prop="items" type="application/json">
    [
      // Long list of items as json
    ]
  </script>
</my-list>

A script element was chosen for the above example, because it is not rendered or parsed by the engine if it has a non-javascript type. The disadvantage of using a script is that it cannot pass in javascript code.

FOUC

There is a flash of unstyled content when an element loads. The element should not render until the styles are fully loaded.

Attributes are stripped out of tables

Non-standard attributes are stripped out of tables. This makes the directive attribute unreadable when the template is processed. There may be other elements for which this is true as well.

This problem requires a big fix. It would feel weird to have a special rule just for times like these, so we can't do that. Instead, all tag directive attributes (including there parameters) need to be prefixed. While we do this, we should also prefix the directive tags.

<tr f:directive="for" f:values="items" f:as="item" #item="index"><td>{{item.name}}</td></tr>

Preliminary testing shows that prefixed attributes (any attribute containing a symbol character) are not stripped from an element when it is parsed by the browser.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.