Reactive, Real-time Frontend Components

Try the Motion Demo

npm

Motion is a framework for building reactive, real-time frontend UI components in your Amber application using pure Crystal that are reusable, testable & encapsulated. For brevity, we will call them MotionComponents.

Table of Contents

Installation

Motion.cr has Crystal and JavaScript parts, execute both of these commands:

dependencies:
  motion.cr:
    github: andrewc910/motion.cr

Create a file motion.cr in config/initializers and add:

require "motion"
# The next require adds the `render` method for components to Amber controllers
require "motion/amber/monkey_patch"

Documentation

Component Guide

MotionComponents are Crystal objects that output HTML. MotionComponents are most effective in cases where view code is reused or benefits from being tested directly. The code itself was pulled & altered from Lucky Framework.

Why should I use components?

Testing

Unlike traditional views, Motion Components can be unit-tested.

Views are typically tested with slow integration tests that also exercise the routing and controller layers in addition to the view. This cost often discourages thorough test coverage.

With MotionComponents, integration tests can be reserved for end-to-end assertions, with permutations and corner cases covered at the unit level.

Data Flow

Traditional views have an implicit interface, making it hard to reason about what information is needed to render, leading to subtle bugs when rendering the same view in different contexts.

MotionComponents use defined props that clearly defines what is needed to render, making them easier (and safer) to reuse than partials.

Standards

Views often fail basic code quality standards: long methods, deep conditional nesting, and mystery guests abound.

MotionComponents are Crystal objects, making it easy to follow (and enforce) code quality standards.

Building components

Conventions

Components are subclasses of Motion::Base and live in views/components. It's common practice to create and inherit from an ApplicationComponent that is a subclass of Motion::Base. By doing so, not only can you share logic, you can share view templates.

Component names end in Component.

Component module names are plural, as for controllers and jobs: Users::AvatarComponent

Quick start

If you followed the installation guide above, you can start with you first component.

  1. Create a components folder in views
  2. Create your first component:
class MyFirstComponent < Motion::Base
  def render
    html_doctype
    head do
      css_link "/css/main.css"
      utf8_charset
      meta content: "text/html;charset=utf-8", http_equiv: "Content-Type"
      title "My First Component"
    end

    body do
      h1 { "My First Component!" }
    end
  end
end
  1. Render it in your controller:
render MyFirstComponent

HTML Generation

For static html rendering, please review the lucky framework documentation

Note: Lucky uses the macro keyword needs, motion uses prop

Props & Type Safety

Props allow you to pass arguements to child components that are type safe. One of the problems with ecr views & partials is, it's hard to reason what variables & data the page requires to render because everything is within scope. Props explicity display what is required for a particular component.

class MyFirstComponent < Motion::Base
  prop title : String

  def render
    html_doctype
    head do
      css_link "/css/main.css"
      utf8_charset
      meta content: "text/html;charset=utf-8", http_equiv: "Content-Type"
      title "My First Component"
    end

    body do
      h1 { @title }
    end
  end
end

In your controller:

render(MyFirstComponent, title: "Hello World")

or rendering from a component:

m(MyFirstComponent, title: "Hello World") # m is shorthand for mount. mount is also acceptable

Blocks & Procs

Blocks & Procs can be passed to child components. This will allow you to create more generic & reusable components.

class MyFirstComponent < Motion::Base
  prop title : Proc(void)

  def render
    html_doctype
    head do
      css_link "/css/main.css"
      utf8_charset
      meta content: "text/html;charset=utf-8", http_equiv: "Content-Type"
      title "My First Component"
    end

    body do
      title.call
    end
  end
end

In your parent component:

title = Proc(void).new { h1 "Hello World!" }
m(MyFirstComponent, title: title)

Motion Guide

Motion.cr allows you to mount special DOM elements that can be updated real-time from frontend interactions, backend state changes, or a combination of both. Some features include:

Motion.cr is similar to Phoenix LiveView (and even React!) in some key ways:

Installation

yarn add @andrewc910/motion.cr

In main.js add:

import { createClient } from '@awcrotwell/motion';

const client = createClient();

Building Motions

Frontend interactions

Frontend interactions can update your MotionComponents using standard JavaScript events that you're already familiar with: change, blur, form submission, and more. Motions default to click events however you can override this to make it any event you would like. You can invoke motions manually using JavaScript if you need to.

The primary way to handle user interactions on the frontend is by setting motion_component to true annotating @[Motion::MapMethod] any motion methods:

# Whenever a user click with the portion of the
# page that contains this component,
# `add` will be invoked, the component will be rerendered
# and the dom will be updated with the new html
class MyMotionComponent < Motion::Base
  # Let motion know this is a motion component
  prop motion_component = true
  # Add your props that you plan to pass in or default
  prop total : Int32 = 0

  # Annotate any motion methods
  @[Motion::MapMethod]
  def add
    @total += 1
  end

  # render is what motion will invoke
  # to generate your components html
  def render
    # data_motion: add tells the motion JS library what method
    # to invoke when a user interacts with this component
    div do
      span do
        @total
        button data_motion: "add" do # data_motion: "add" defaults to a click event
                                     # data_motion: "mouseover->add" to make it a mouseover event
                                     # data_motion: "mouseover->add mouseout->add" to map multiple events to a single element
          "Increment" # button text
        end
      end
    end
  end
end

class MyFirstComponent < Motion::Base
  def render
    html_doctype
    head do
      css_link "/css/main.css"
      utf8_charset
      meta content: "text/html;charset=utf-8", http_equiv: "Content-Type"
      title "My First Component"
    end

    body do
      m(MyFirstMotionComponent)
    end
  end
end

This component can be rendered from your controller:

render MyFirstComponent

Every time the "Increment" button is clicked, MyComponent will call the add method, re-render your component and send it back to the frontend to replace the existing DOM. All invocations of mapped motions will cause the component to re-render, and unchanged rendered HTML will not perform any changes.

Periodic Timers

Motion can automatically invoke a method on your component at regular intervals:

class TickerComponent < Motion::Base
  props ticker : Int32 = 0
  props motion_component : Bool = true

  @[Motion::PeriodicTimer(interval: 1.second)]
  def tick
    @ticker += 1
  end

  def render
    div do
      span @ticker.to_s
    end
  end
end

In this example, after the component has mounted to a websockets channel, tick will be invoked every second and the component will be rerendered on the frontend.

Motion::Event and Motion::Element

Methods that are mapped using @[Motion::MapMethod] can choose to accept an event parameter which is a Motion::Event. This object has a target attribute which is a Motion::Element, the element in the DOM that triggered the motion. Useful state and attributes can be extracted from these objects, including value, selected, checked, form state, data attributes, and more.

  @[Motion::MapMethod]
  def example(event)
    event.type # => "change"
    event.name # alias for type

    # Motion::Element instance, the element that received the event.
    event.target

    # Motion::Element instance, the element with the event handler and the `data-motion` attribute
    element = event.current_target
    # Alias for #current_target
    event.element


    # Element API examples
    element.tag_name # => "input"
    element.value # => "5"
    element.attributes # { class: "col-xs-12", ... }

    # DOM element with aria-label="..."
    element[:aria_label]

    # DOM element with data-extra-info="..."
    element.data[:extra_info]

    # ActionController::Parameters instance with all form params. Also
    # available on Motion::Event objects for convenience.
    element.form_data
  end

See the code for full API for Event and Element.

Limitations

Roadmap

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/andrewc910/motion.cr/issues.

License

The shard is available as open source under the terms of the MIT License.