A minimalist framework for binding reactive views to observable models
Television is a reactive view framework based on the "model-driven views" paradigm. Views are simple, declarative wrappers around underlying model objects, enforcing a separation between application logic and display logic. Like most good ideas, this one isn't new, but Television provides a unique combination of features to make working in this style intuitive and productive.
Massive props to the Rivets.js framework, which inspired Television's approach to binding.
A convenient (but option) way to define a view is by creating a
require 'television'@content: """<div class="blog"><h1>My Blog</h1></div>"""
This is a simple view for
Blog models. The name of a view class should be
based on the name of the model class it's intended to visualize (with
appended to the end). Note the
@content class-level property. It contains a
string of HTML on which the view's DOM element will be based. The
property can also be a DOM element or a function that returns a string or DOM
element. This makes it easy to integrate with third-party templating frameworks
if you choose to do so. Television also includes a markup DSL, which is
convenient if you just want to specify your content with code:
: ->@div class: 'blog'=> @h1 "My Blog"
Now that we've defined a view class, we can use it to construct a view. Television currently only works with Telepath models, so we'll define one first. It should be easy to allow other types of models in the future.
require 'telepath'@properties 'title''author''posts'blog = BlogcreateAsRoottitle: "Cats"view = blogexpectviewelementouterHTMLtoBe """<div class="blog"><h1>My Blog</h1></div>"""expectviewmodeltoBe blog
Note that the view instance we constructed has an
element and a
property, and that the element is based on the
@content property of the view
Bindings allow you to declaratively wire your view's HTML content to properties on the view's model. When the view is created, the content is populated based on the model you pass in. If properties on the model change, the view is updated automatically.
In the example above, we have a
title property on the blog model that we'd
like to appear in the view. To instruct Television to associate the title with
the text content of the
h1 tag, we use the
@content: """<div class="blog"><h1 x-bind-text="title">My Blog</h1></div>"""blog = BlogcreateAsRoottitle: "Cats"view = blogexpectviewelementouterHTMLtoBe """<div class="blog"><h1 x-bind-text="title">Cats</h1></div>"""# updates view automaticallyblog.title = "Dogs"expectviewelementouterHTMLtoBe """<div class="blog"><h1 x-bind-text="title">Dogs</h1></div>"""
The blog model also has an
author property, which contains a reference to a
User model. Let's define a
UserView, which has an avatar for the user and
their name. To assign the value of the
src property on the image, we'll use
@properties 'name''avatarUrl': -> """<div class="user"><img class="avatar" x-bind-attribute-src="avatarUrl" src="placeholder.png"><div class="name" x-bind-text="name">Name</div></div>"""
Note that the name of the bound attribute is appended to the end of the binding
name. If the value of the bound property isn't defined, the placeholder value
placeholder.png is used instead. If you don't define a placeholder value,
the attribute will be removed when the bound value is undefined.
Style bindings work similarly to attribute bindings, except they bind to named style properties instead of attributes.
: -> """<div class="user" x-bind-style-background-color="favoriteColor"></div>"""view = useruser.favoriteColor = "red"expectviewelementstylebackgroundColortoBe "red"
Now we want to include an instance of the
UserView inside our
bound to the
author property on the blog. To do that, we'll use a component
@register UserView@content: """<div class='blog'><h1 x-bind-text="title">My Blog</h1><div x-bind-component="author"></div></div>"""
First, we call
BlogView with the
UserView class. This adds
to the set of views that
BlogView will consider when constructing a view for
bound components. If
BlogView were itself embedded as a component in another
view didn't have a registered view for
User, it would search upward through
its ancestors for a view that matches. Here's the component binding in action:
author =blog = BlogcreateAsRoottitle: "Cats"author:name: "Nathan Sobo"avatarUrl: "/images/nathan.png"view = blogexpectviewelementouterHTMLtoBe """<div class="blog"><h1 x-bind-text="title">Cats</h1><div class="user"><img class="avatar" x-bind-attribute-src="avatarUrl" src="/images/nathan.png"><div class="name" x-bind-text="name">Nathan Sobo</div></div></div>"""
Now we want to include a summary of each of the blog's posts. We assign
@modelClassName explicitly to 'Post' since the view name does not match the
standard Model Class Name + "View" pattern. For that, we'll use the
x-bind-collection directive. First, we define the post summary view, then bind
a list to a collection of posts on the blog.
@modelClassName: 'Post'@content: """<div class="post-summary"><h2 x-bind-text="title">Title</h2><div x-bind-text="summary"></div></div>"""@register UserView@register PostSummary@content: """<div class="blog"><h1 x-bind-text="title">My Blog</h1><div x-bind-component="author"></div><ol x-bind-collection="posts"></ol></div>"""
Formatters transform the value of a property to prepare it for display. You
apply a formatter with a
| character following the bound property name.
append formatter appends a specified string to the value of a property.
You should concentrate the majority of your application logic in the model layer and use declarative bindings to wire it to the view. You can even design your own custom binders if the built-in binders don't cover your needs, which we'll discuss later. But sometimes you're going to need custom view logic, and for that you'll use custom view methods.
If you define the
destroyed instance methods on your view
classes, they will be called at the appropriate time. Note that Television
performs caching in certain circumstances, so
destroyed is only guaranteed to
be called when the underlying model object is detached from the document.
: -> # ...: ->startCrazyAnimation@element: ->stopCrazyAnimation@element
You can also define instance methods on your view class, just like you can for any normal class. Just be careful not to put logic in the view that belongs in the model.
: -> # ...: # ...view = userviewaddMoustache"handlebar"
The examples above instantiate the blog view directly, which is good for testing or more isolated use. A more holistic approach is to create a global registration point from which all the application's views descend. This allows third parties to easily register new kinds of views, which can then be displayed as components anywhere in the application.
television = require 'television'tv = televisiontvregisterBlogViewtvregisterPostViewtvregisterUserViewblogView = tvbuildViewblogtvregisterSpecialUserView # add a view for a new typeblog.author = # the new view will automatically be used by BlogView
All registered views are available as properties on whatever you register them
on. So you can access
If you don't want to subclass
View, you can use the
::buildViewFactory methods. The
content properties are mandatory.
Any other properties will be added to the constructed view objects.
television = require 'television'tv = televisiontvregistername: 'BlogView'content: # ...: -> # ...: -> # ...: -> # ...view = tvbuildViewblogviewcustomMethod# or create a standalone factoryfactory = tvbuildViewFactory name: 'BlogView' content: # ...view = factorybuildViewblog
You can register your own binders by calling
::registerBinder on any view
class or view factory, including the global registry.
tvregisterBinder 'display':readeronValueif valueelement.style.display = 'block'elseelement.style.display = 'none':subscriptionoff
::registerBinder with an object containing an
id property and two
id property can be a string or a regular
expression that will be matched against the suffix of
When an element with a matching attribute is found, your
bind method will be
called with an object containing the following properties:
idThe suffix of the
factoryThe factory that built the view containing the bound element.
elementThe element being bound.
readerA behavior that yields a value on subscription and whenever the bound property changes on the model. Subscribe to its 'value' events and update the element whenever your callback is triggered.
Whatever you return from the
bind method will be passed to
unbind when the
binding needs to be destroyed.
::registerFormatter to register custom formatters for use with the
tvregisterFormatterid: 'prepend':prefixtoString + valuetoString
id property specifies how users of your formatter will refer to it
| in expressions. Bidirectional bindings aren't working yet, so
formatters only have a
read method for now (eventually they'll have an
write method). The
read method will be called with a value to
format followed by the arguments specified in the expression.