Commit 24b83cdb authored by Øyvind Raddum Berg's avatar Øyvind Raddum Berg

TodoMvc example for scalajs-react

parent 557e65c3
......@@ -29,7 +29,8 @@
"examples/vanilladart/**/*.js",
"examples/duel/www/**",
"examples/duel/src/main/webapp/js/lib/**",
"examples/polymer/elements/elements.build.js"
"examples/polymer/elements/elements.build.js",
"**/generated/**"
],
"requireSpaceBeforeBlockStatements": true,
"requireParenthesesAroundIIFE": true,
......
root = false
[*.scala]
indent_style = space
indent_size = 2
target
node_modules/todomvc-common
!node_modules/todomvc-common/base.js
!node_modules/todomvc-common/base.css
node_modules/todomvc-app-css
!node_modules/todomvc-app-css/index.css
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
((typeof global === "object" && global &&
global["Object"] === Object) ? global : this)["todomvc"]["Main"]().main();
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
<!doctype html>
<html lang="en" data-framework="scalajs-react">
<head>
<meta charset="utf-8">
<title>scalajs-react • TodoMVC</title>
<link rel="stylesheet" href="node_modules/todomvc-common/base.css">
<link rel="stylesheet" href="node_modules/todomvc-app-css/index.css">
</head>
<body>
<section class="todoapp"></section>
<footer class="info">
<p>Double-click to edit a todo</p>
<p>Created by <a href="http://github.com/elacin/">Øyvind Raddum Berg</a></p>
<p>Part of <a href="http://todomvc.com">TodoMVC</a></p>
</footer>
<script src="node_modules/todomvc-common/base.js"></script>
<script src="generated/todomvc-jsdeps.js"></script>
<!-- include application -->
<script src="generated/todomvc-opt.js"></script>
<!-- run application -->
<script src="generated/todomvc-launcher.js"></script>
</body>
</html>
html,
body {
margin: 0;
padding: 0;
}
button {
margin: 0;
padding: 0;
border: 0;
background: none;
font-size: 100%;
vertical-align: baseline;
font-family: inherit;
font-weight: inherit;
color: inherit;
-webkit-appearance: none;
appearance: none;
-webkit-font-smoothing: antialiased;
-moz-font-smoothing: antialiased;
font-smoothing: antialiased;
}
body {
font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
line-height: 1.4em;
background: #f5f5f5;
color: #4d4d4d;
min-width: 230px;
max-width: 550px;
margin: 0 auto;
-webkit-font-smoothing: antialiased;
-moz-font-smoothing: antialiased;
font-smoothing: antialiased;
font-weight: 300;
}
button,
input[type="checkbox"] {
outline: none;
}
.hidden {
display: none;
}
.todoapp {
background: #fff;
margin: 130px 0 40px 0;
position: relative;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2),
0 25px 50px 0 rgba(0, 0, 0, 0.1);
}
.todoapp input::-webkit-input-placeholder {
font-style: italic;
font-weight: 300;
color: #e6e6e6;
}
.todoapp input::-moz-placeholder {
font-style: italic;
font-weight: 300;
color: #e6e6e6;
}
.todoapp input::input-placeholder {
font-style: italic;
font-weight: 300;
color: #e6e6e6;
}
.todoapp h1 {
position: absolute;
top: -155px;
width: 100%;
font-size: 100px;
font-weight: 100;
text-align: center;
color: rgba(175, 47, 47, 0.15);
-webkit-text-rendering: optimizeLegibility;
-moz-text-rendering: optimizeLegibility;
text-rendering: optimizeLegibility;
}
.new-todo,
.edit {
position: relative;
margin: 0;
width: 100%;
font-size: 24px;
font-family: inherit;
font-weight: inherit;
line-height: 1.4em;
border: 0;
outline: none;
color: inherit;
padding: 6px;
border: 1px solid #999;
box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-font-smoothing: antialiased;
font-smoothing: antialiased;
}
.new-todo {
padding: 16px 16px 16px 60px;
border: none;
background: rgba(0, 0, 0, 0.003);
box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03);
}
.main {
position: relative;
z-index: 2;
border-top: 1px solid #e6e6e6;
}
label[for='toggle-all'] {
display: none;
}
.toggle-all {
position: absolute;
top: -55px;
left: -12px;
width: 60px;
height: 34px;
text-align: center;
border: none; /* Mobile Safari */
}
.toggle-all:before {
content: '❯';
font-size: 22px;
color: #e6e6e6;
padding: 10px 27px 10px 27px;
}
.toggle-all:checked:before {
color: #737373;
}
.todo-list {
margin: 0;
padding: 0;
list-style: none;
}
.todo-list li {
position: relative;
font-size: 24px;
border-bottom: 1px solid #ededed;
}
.todo-list li:last-child {
border-bottom: none;
}
.todo-list li.editing {
border-bottom: none;
padding: 0;
}
.todo-list li.editing .edit {
display: block;
width: 506px;
padding: 13px 17px 12px 17px;
margin: 0 0 0 43px;
}
.todo-list li.editing .view {
display: none;
}
.todo-list li .toggle {
text-align: center;
width: 40px;
/* auto, since non-WebKit browsers doesn't support input styling */
height: auto;
position: absolute;
top: 0;
bottom: 0;
margin: auto 0;
border: none; /* Mobile Safari */
-webkit-appearance: none;
appearance: none;
}
.todo-list li .toggle:after {
content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="-10 -18 100 135"><circle cx="50" cy="50" r="50" fill="none" stroke="#ededed" stroke-width="3"/></svg>');
}
.todo-list li .toggle:checked:after {
content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="-10 -18 100 135"><circle cx="50" cy="50" r="50" fill="none" stroke="#bddad5" stroke-width="3"/><path fill="#5dc2af" d="M72 25L42 71 27 56l-4 4 20 20 34-52z"/></svg>');
}
.todo-list li label {
white-space: pre;
word-break: break-word;
padding: 15px 60px 15px 15px;
margin-left: 45px;
display: block;
line-height: 1.2;
transition: color 0.4s;
}
.todo-list li.completed label {
color: #d9d9d9;
text-decoration: line-through;
}
.todo-list li .destroy {
display: none;
position: absolute;
top: 0;
right: 10px;
bottom: 0;
width: 40px;
height: 40px;
margin: auto 0;
font-size: 30px;
color: #cc9a9a;
margin-bottom: 11px;
transition: color 0.2s ease-out;
}
.todo-list li .destroy:hover {
color: #af5b5e;
}
.todo-list li .destroy:after {
content: '×';
}
.todo-list li:hover .destroy {
display: block;
}
.todo-list li .edit {
display: none;
}
.todo-list li.editing:last-child {
margin-bottom: -1px;
}
.footer {
color: #777;
padding: 10px 15px;
height: 20px;
text-align: center;
border-top: 1px solid #e6e6e6;
}
.footer:before {
content: '';
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 50px;
overflow: hidden;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2),
0 8px 0 -3px #f6f6f6,
0 9px 1px -3px rgba(0, 0, 0, 0.2),
0 16px 0 -6px #f6f6f6,
0 17px 2px -6px rgba(0, 0, 0, 0.2);
}
.todo-count {
float: left;
text-align: left;
}
.todo-count strong {
font-weight: 300;
}
.filters {
margin: 0;
padding: 0;
list-style: none;
position: absolute;
right: 0;
left: 0;
}
.filters li {
display: inline;
}
.filters li a {
color: inherit;
margin: 3px;
padding: 3px 7px;
text-decoration: none;
border: 1px solid transparent;
border-radius: 3px;
}
.filters li a.selected,
.filters li a:hover {
border-color: rgba(175, 47, 47, 0.1);
}
.filters li a.selected {
border-color: rgba(175, 47, 47, 0.2);
}
.clear-completed,
html .clear-completed:active {
float: right;
position: relative;
line-height: 20px;
text-decoration: none;
cursor: pointer;
position: relative;
}
.clear-completed:hover {
text-decoration: underline;
}
.info {
margin: 65px auto 0;
color: #bfbfbf;
font-size: 10px;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
text-align: center;
}
.info p {
line-height: 1;
}
.info a {
color: inherit;
text-decoration: none;
font-weight: 400;
}
.info a:hover {
text-decoration: underline;
}
/*
Hack to remove background from Mobile Safari.
Can't use it globally since it destroys checkboxes in Firefox
*/
@media screen and (-webkit-min-device-pixel-ratio:0) {
.toggle-all,
.todo-list li .toggle {
background: none;
}
.todo-list li .toggle {
height: 40px;
}
.toggle-all {
-webkit-transform: rotate(90deg);
transform: rotate(90deg);
-webkit-appearance: none;
appearance: none;
}
}
@media (max-width: 430px) {
.footer {
height: 50px;
}
.filters {
bottom: 10px;
}
}
hr {
margin: 20px 0;
border: 0;
border-top: 1px dashed #c5c5c5;
border-bottom: 1px dashed #f7f7f7;
}
.learn a {
font-weight: normal;
text-decoration: none;
color: #b83f45;
}
.learn a:hover {
text-decoration: underline;
color: #787e7e;
}
.learn h3,
.learn h4,
.learn h5 {
margin: 10px 0;
font-weight: 500;
line-height: 1.2;
color: #000;
}
.learn h3 {
font-size: 24px;
}
.learn h4 {
font-size: 18px;
}
.learn h5 {
margin-bottom: 0;
font-size: 14px;
}
.learn ul {
padding: 0;
margin: 0 0 30px 25px;
}
.learn li {
line-height: 20px;
}
.learn p {
font-size: 15px;
font-weight: 300;
line-height: 1.3;
margin-top: 0;
margin-bottom: 0;
}
#issue-count {
display: none;
}
.quote {
border: none;
margin: 20px 0 60px 0;
}
.quote p {
font-style: italic;
}
.quote p:before {
content: '“';
font-size: 50px;
opacity: .15;
position: absolute;
top: -20px;
left: 3px;
}
.quote p:after {
content: '”';
font-size: 50px;
opacity: .15;
position: absolute;
bottom: -42px;
right: 3px;
}
.quote footer {
position: absolute;
bottom: -40px;
right: 0;
}
.quote footer img {
border-radius: 3px;
}
.quote footer a {
margin-left: 5px;
vertical-align: middle;
}
.speech-bubble {
position: relative;
padding: 10px;
background: rgba(0, 0, 0, .04);
border-radius: 5px;
}
.speech-bubble:after {
content: '';
position: absolute;
top: 100%;
right: 30px;
border: 13px solid transparent;
border-top-color: rgba(0, 0, 0, .04);
}
.learn-bar > .learn {
position: absolute;
width: 272px;
top: 8px;
left: -300px;
padding: 10px;
border-radius: 5px;
background-color: rgba(255, 255, 255, .6);
transition-property: left;
transition-duration: 500ms;
}
@media (min-width: 899px) {
.learn-bar {
width: auto;
padding-left: 300px;
}
.learn-bar > .learn {
left: 8px;
}
}
/* global _ */
(function () {
'use strict';
/* jshint ignore:start */
// Underscore's Template Module
// Courtesy of underscorejs.org
var _ = (function (_) {
_.defaults = function (object) {
if (!object) {
return object;
}
for (var argsIndex = 1, argsLength = arguments.length; argsIndex < argsLength; argsIndex++) {
var iterable = arguments[argsIndex];
if (iterable) {
for (var key in iterable) {
if (object[key] == null) {
object[key] = iterable[key];
}
}
}
}
return object;
}
// By default, Underscore uses ERB-style template delimiters, change the
// following template settings to use alternative delimiters.
_.templateSettings = {
evaluate : /<%([\s\S]+?)%>/g,
interpolate : /<%=([\s\S]+?)%>/g,
escape : /<%-([\s\S]+?)%>/g
};
// When customizing `templateSettings`, if you don't want to define an
// interpolation, evaluation or escaping regex, we need one that is
// guaranteed not to match.
var noMatch = /(.)^/;
// Certain characters need to be escaped so that they can be put into a
// string literal.
var escapes = {
"'": "'",
'\\': '\\',
'\r': 'r',
'\n': 'n',
'\t': 't',
'\u2028': 'u2028',
'\u2029': 'u2029'
};
var escaper = /\\|'|\r|\n|\t|\u2028|\u2029/g;
// JavaScript micro-templating, similar to John Resig's implementation.
// Underscore templating handles arbitrary delimiters, preserves whitespace,
// and correctly escapes quotes within interpolated code.
_.template = function(text, data, settings) {
var render;
settings = _.defaults({}, settings, _.templateSettings);
// Combine delimiters into one regular expression via alternation.
var matcher = new RegExp([
(settings.escape || noMatch).source,
(settings.interpolate || noMatch).source,
(settings.evaluate || noMatch).source
].join('|') + '|$', 'g');
// Compile the template source, escaping string literals appropriately.
var index = 0;
var source = "__p+='";
text.replace(matcher, function(match, escape, interpolate, evaluate, offset) {
source += text.slice(index, offset)
.replace(escaper, function(match) { return '\\' + escapes[match]; });
if (escape) {
source += "'+\n((__t=(" + escape + "))==null?'':_.escape(__t))+\n'";
}
if (interpolate) {
source += "'+\n((__t=(" + interpolate + "))==null?'':__t)+\n'";
}
if (evaluate) {
source += "';\n" + evaluate + "\n__p+='";
}
index = offset + match.length;
return match;
});
source += "';\n";
// If a variable is not specified, place data values in local scope.
if (!settings.variable) source = 'with(obj||{}){\n' + source + '}\n';
source = "var __t,__p='',__j=Array.prototype.join," +
"print=function(){__p+=__j.call(arguments,'');};\n" +
source + "return __p;\n";
try {
render = new Function(settings.variable || 'obj', '_', source);
} catch (e) {
e.source = source;
throw e;
}
if (data) return render(data, _);
var template = function(data) {
return render.call(this, data, _);
};
// Provide the compiled function source as a convenience for precompilation.
template.source = 'function(' + (settings.variable || 'obj') + '){\n' + source + '}';
return template;
};
return _;
})({});
if (location.hostname === 'todomvc.com') {
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
ga('create', 'UA-31081062-1', 'auto');
ga('send', 'pageview');
}
/* jshint ignore:end */
function redirect() {
if (location.hostname === 'tastejs.github.io') {
location.href = location.href.replace('tastejs.github.io/todomvc', 'todomvc.com');
}
}
function findRoot() {
var base = location.href.indexOf('examples/');
return location.href.substr(0, base);
}
function getFile(file, callback) {
if (!location.host) {
return console.info('Miss the info bar? Run TodoMVC from a server to avoid a cross-origin error.');
}
var xhr = new XMLHttpRequest();
xhr.open('GET', findRoot() + file, true);
xhr.send();
xhr.onload = function () {
if (xhr.status === 200 && callback) {
callback(xhr.responseText);
}
};
}
function Learn(learnJSON, config) {
if (!(this instanceof Learn)) {
return new Learn(learnJSON, config);
}
var template, framework;
if (typeof learnJSON !== 'object') {
try {
learnJSON = JSON.parse(learnJSON);
} catch (e) {
return;
}
}
if (config) {
template = config.template;
framework = config.framework;
}
if (!template && learnJSON.templates) {
template = learnJSON.templates.todomvc;
}
if (!framework && document.querySelector('[data-framework]')) {
framework = document.querySelector('[data-framework]').dataset.framework;
}
this.template = template;
if (learnJSON.backend) {
this.frameworkJSON = learnJSON.backend;
this.frameworkJSON.issueLabel = framework;
this.append({
backend: true
});
} else if (learnJSON[framework]) {
this.frameworkJSON = learnJSON[framework];
this.frameworkJSON.issueLabel = framework;
this.append();
}
this.fetchIssueCount();
}
Learn.prototype.append = function (opts) {
var aside = document.createElement('aside');
aside.innerHTML = _.template(this.template, this.frameworkJSON);
aside.className = 'learn';
if (opts && opts.backend) {
// Remove demo link
var sourceLinks = aside.querySelector('.source-links');
var heading = sourceLinks.firstElementChild;
var sourceLink = sourceLinks.lastElementChild;
// Correct link path
var href = sourceLink.getAttribute('href');
sourceLink.setAttribute('href', href.substr(href.lastIndexOf('http')));
sourceLinks.innerHTML = heading.outerHTML + sourceLink.outerHTML;
} else {
// Localize demo links
var demoLinks = aside.querySelectorAll('.demo-link');
Array.prototype.forEach.call(demoLinks, function (demoLink) {
if (demoLink.getAttribute('href').substr(0, 4) !== 'http') {
demoLink.setAttribute('href', findRoot() + demoLink.getAttribute('href'));
}
});
}
document.body.className = (document.body.className + ' learn-bar').trim();
document.body.insertAdjacentHTML('afterBegin', aside.outerHTML);
};
Learn.prototype.fetchIssueCount = function () {
var issueLink = document.getElementById('issue-count-link');
if (issueLink) {
var url = issueLink.href.replace('https://github.com', 'https://api.github.com/repos');
var xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.onload = function (e) {
var parsedResponse = JSON.parse(e.target.responseText);
if (parsedResponse instanceof Array) {
var count = parsedResponse.length;
if (count !== 0) {
issueLink.innerHTML = 'This app has ' + count + ' open issues';
document.getElementById('issue-count').style.display = 'inline';
}
}
};
xhr.send();
}
};
redirect();
getFile('learn.json', Learn);
})();
{
"private": true,
"dependencies": {
"todomvc-app-css": "^2.0.1",
"todomvc-common": "^1.0.2"
}
}
import sbt._
import Keys._
import org.scalajs.sbtplugin.ScalaJSPlugin
import ScalaJSPlugin.autoImport._
object Build extends Build {
val scalaJsReactVersion = "0.9.1"
val generatedDir = file("generated")
val todomvc = Project("todomvc", file("."))
.enablePlugins(ScalaJSPlugin)
.settings(
organization := "com.olvind",
version := "1",
licenses += ("Apache-2.0", url("http://opensource.org/licenses/Apache-2.0")),
scalaVersion := "2.11.7",
scalacOptions ++= Seq("-deprecation", "-encoding", "UTF-8", "-feature", "-unchecked", "-Xlint", "-Yno-adapted-args", "-Ywarn-dead-code" ),
updateOptions := updateOptions.value.withCachedResolution(true),
sbt.Keys.test in Test := (),
emitSourceMaps := true,
/* create javascript launcher. Searches for an object extends JSApp */
persistLauncher := true,
/* javascript dependencies */
jsDependencies += "org.webjars" % "react" % "0.12.1" /
"react-with-addons.js" commonJSName "React" minified "react-with-addons.min.js",
/* scala.js dependencies */
libraryDependencies ++= Seq(
"com.github.japgolly.scalajs-react" %%% "ext-scalaz71" % scalaJsReactVersion,
"com.github.japgolly.scalajs-react" %%% "extra" % scalaJsReactVersion,
"com.lihaoyi" %%% "upickle" % "0.3.0"
),
/* move these files out of target/. Also sets up same file for both fast and full optimization */
crossTarget in (Compile, fullOptJS) := generatedDir,
crossTarget in (Compile, fastOptJS) := generatedDir,
crossTarget in (Compile, packageJSDependencies) := generatedDir,
crossTarget in (Compile, packageScalaJSLauncher) := generatedDir,
crossTarget in (Compile, packageMinifiedJSDependencies) := generatedDir,
artifactPath in (Compile, fastOptJS) :=
((crossTarget in (Compile, fastOptJS)).value / ((moduleName in fastOptJS).value + "-opt.js"))
)
}
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "0.6.5")
# Scalajs-React TodoMVC Example
> Lifts Facebook's [React](https://facebook.github.io/react/) library into
> [Scala.js](http://www.scala-js.org/) and endeavours to make it as type-safe
> and Scala-friendly as possible.
>
> Provides (opt-in) support for pure functional programming, using
> [Scalaz](https://github.com/scalaz/scalaz) and
> [Monocle](https://github.com/julien-truffaut/Monocle).
>
> Comes with utility modules
> [`extra`](https://github.com/japgolly/scalajs-react/extra/)
> and [`test`](https://github.com/japgolly/scalajs-react/test/),
> helpful for React in Scala(.js), rather than React in JS.
> Includes a router, testing utils, performance utils, more.
>
> _[Scalajs-React - https://github.com/japgolly/scalajs-react/](https://github.com/japgolly/scalajs-react/)_
## Learning Scala
* [Documentation](http://scala-lang.org/documentation/)
* [API Reference](http://www.scala-lang.org/api/2.11.6/)
* [Functional Programming Principles in Scala, free on Coursera.](https://www.coursera.org/course/progfun)
* [Tutorials](http://docs.scala-lang.org/tutorials/)
## Learning React
The [React getting started documentation](http://facebook.github.io/react/docs/getting-started.html)
is a great way to get started.
Here are some links you may find helpful:
* [Documentation](http://facebook.github.io/react/docs/getting-started.html)
* [API Reference](http://facebook.github.io/react/docs/reference.html)
* [Blog](http://facebook.github.io/react/blog/)
* [React on GitHub](https://github.com/facebook/react)
* [Support](http://facebook.github.io/react/support.html)
More links under the other React TodoMVC examples: [React example](../react)
and [React-Backbone example](../react-backbone)
## Learning Scala.js
* [Homepage](http://www.scala-js.org/)
* [Tutorial](http://www.scala-js.org/doc/tutorial.html)
## Learning Scalajs-React
* [Github/Documentation](https://github.com/japgolly/scalajs-react/)
* [Documentation/Extras](https://github.com/japgolly/scalajs-react/blob/master/extra/README.md)
## Development
The build tool for this project is [sbt](http://www.scala-sbt.org), which is
set up with a [plugin](http://www.scala-js.org/doc/sbt-plugin.html)
to enable compilation and packaging of Scala.js web applications.
The scala.js plugin for sbt supports two compilation modes:
* `fullOptJS` is a full program optimization, which is annoyingly slow when developing
* `fastOptJS` is fast, but produces large generated javascript files. This is the one we use for development.
After installation, you start `sbt` like this:
```
$ sbt
```
After `sbt` has downloaded the internet, you can compile once like this:
```
sbt> fastOptJS
```
or enable continuous compilation by prefixing a tilde
```
sbt> ~fastOptJS
```
Happy hacking! :)
package todomvc
import japgolly.scalajs.react.ScalazReact._
import japgolly.scalajs.react._
import japgolly.scalajs.react.vdom.prefix_<^._
import scalaz.effect.IO
import scalaz.syntax.std.list._
import scalaz.syntax.std.string._
object CFooter {
case class Props private[CFooter](
filterLink: TodoFilter => ReactTag,
onClearCompleted: IO[Unit],
currentFilter: TodoFilter,
activeCount: Int,
completedCount: Int
)
case class Backend($: BackendScope[Props, Unit]) {
val clearButton =
<.button(^.className := "clear-completed", ^.onClick ~~> $.props.onClearCompleted, "Clear completed")
def filterLink(s: TodoFilter) =
<.li($.props.filterLink(s)(($.props.currentFilter == s) ?= (^.className := "selected"), s.title))
def withSpaces(ts: TagMod*) =
ts.toList.intersperse(" ")
def render =
<.footer(
^.className := "footer",
<.span(
^.className := "todo-count",
withSpaces(<.strong($.props.activeCount), "item" plural $.props.activeCount, "left")
),
<.ul(
^.className := "filters",
withSpaces(TodoFilter.values map filterLink)
),
clearButton(($.props.completedCount == 0) ?= ^.visibility.hidden)
)
}
private val component = ReactComponentB[Props]("CFooter")
.stateless
.backend(Backend)
.render(_.backend.render)
.build
def apply(filterLink: TodoFilter => ReactTag,
onClearCompleted:IO[Unit],
currentFilter: TodoFilter,
activeCount: Int,
completedCount: Int) =
component(
Props(
filterLink = filterLink,
onClearCompleted = onClearCompleted,
currentFilter = currentFilter,
activeCount = activeCount,
completedCount = completedCount
)
)
}
package todomvc
import japgolly.scalajs.react.ScalazReact._
import japgolly.scalajs.react._
import japgolly.scalajs.react.vdom.prefix_<^._
import org.scalajs.dom.ext.KeyCode
import scalaz.effect.IO
import scalaz.syntax.semigroup._
import scalaz.syntax.std.option._
import scalaz.std.anyVal.unitInstance
object CTodoItem {
case class Props private[CTodoItem] (
onToggle: IO[Unit],
onDelete: IO[Unit],
onStartEditing: IO[Unit],
onUpdateTitle: Title => IO[Unit],
onCancelEditing: IO[Unit],
todo: Todo,
isEditing: Boolean
)
case class State(editText: UnfinishedTitle)
case class Backend($: BackendScope[Props, State]) {
def editFieldSubmit: IO[Unit] =
$.state.editText.validated.fold($.props.onDelete)($.props.onUpdateTitle)
/**
* It's OK to make these into `val`s as long as they don't touch state.
*/
val resetText: IO[Unit] =
$.modStateIO(_.copy(editText = $.props.todo.title.editable))
val editFieldKeyDown: ReactKeyboardEvent => Option[IO[Unit]] =
e => e.nativeEvent.keyCode match {
case KeyCode.Escape => (resetText |+| $.props.onCancelEditing).some
case KeyCode.Enter => editFieldSubmit.some
case _ => None
}
val editFieldChanged: ReactEventI => IO[Unit] =
e => $.modStateIO(_.copy(editText = UnfinishedTitle(e.target.value)))
def render: ReactElement = {
<.li(
^.classSet(
"completed" -> $.props.todo.isCompleted,
"editing" -> $.props.isEditing
),
<.div(
^.className := "view",
<.input(
^.className := "toggle",
^.`type` := "checkbox",
^.checked := $.props.todo.isCompleted,
^.onChange ~~> $.props.onToggle
),
<.label($.props.todo.title.value, ^.onDoubleClick ~~> $.props.onStartEditing),
<.button(^.className := "destroy", ^.onClick ~~> $.props.onDelete)
),
<.input(
^.className := "edit",
^.onBlur ~~> editFieldSubmit,
^.onChange ~~> editFieldChanged,
^.onKeyDown ~~>? editFieldKeyDown,
^.value := $.state.editText.value
)
)
}
}
private val component = ReactComponentB[Props]("CTodoItem")
.initialStateP(p => State(p.todo.title.editable))
.backend(Backend)
.render(_.backend.render)
.build
def apply(onToggle: IO[Unit],
onDelete: IO[Unit],
onStartEditing: IO[Unit],
onUpdateTitle: Title => IO[Unit],
onCancelEditing: IO[Unit],
todo: Todo,
isEditing: Boolean) =
component.withKey(todo.id.id.toString)(
Props(
onToggle = onToggle,
onDelete = onDelete,
onStartEditing = onStartEditing,
onUpdateTitle = onUpdateTitle,
onCancelEditing = onCancelEditing,
todo = todo,
isEditing = isEditing
)
)
}
package todomvc
import japgolly.scalajs.react.ScalazReact._
import japgolly.scalajs.react._
import japgolly.scalajs.react.extra._
import japgolly.scalajs.react.extra.router2.RouterCtl
import japgolly.scalajs.react.vdom.prefix_<^._
import org.scalajs.dom.ext.KeyCode
import scala.scalajs.js
import scalaz.effect.IO
import scalaz.std.anyVal.unitInstance
import scalaz.syntax.semigroup._
object CTodoList {
case class Props private[CTodoList] (ctl: RouterCtl[TodoFilter], model: TodoModel, currentFilter: TodoFilter)
case class State(todos: Seq[Todo], editing: Option[TodoId])
/**
* These specify when it makes sense to skip updating this component (see comment on `Listenable` below)
*/
implicit val r1 = Reusability.fn[Props]((p1, p2) => p1.currentFilter == p2.currentFilter)
implicit val r2 = Reusability.fn[State]((s1, s2) => s1.editing == s2.editing && (s1.todos eq s2.todos))
/**
* One difference between normal react and scalajs-react is the use of backends.
* Since components are not inheritance-based, we often use a backend class
* where we put most of the functionality: rendering, state handling, etc.
*
* It extends OnUnmount so unsubscription of events can be made automatically.
*/
case class Backend($: BackendScope[Props, State]) extends OnUnmount {
def handleNewTodoKeyDown(event: ReactKeyboardEventI): Option[IO[Unit]] =
Some((event.nativeEvent.keyCode, UnfinishedTitle(event.target.value).validated)) collect {
case (KeyCode.Enter, Some(title)) =>
IO(event.target.value = "") |+| $.props.model.addTodo(title)
}
def updateTitle(id: TodoId)(title: Title): IO[Unit] =
editingDone(cb = $.props.model.update(id, title))
def startEditing(id: TodoId): IO[Unit] =
$.modStateIO(_.copy(editing = Some(id)))
/**
* @param cb Two changes to the same `State` must be combined using a callback like this.
* If not, rerendering will prohibit the second from having its effect.
* For this example, the current `State` contains both `editing` and the list of todos.
*/
def editingDone(cb: OpCallbackIO = js.undefined): IO[Unit] =
$.modStateIO(_.copy(editing = None), cb)
def toggleAll(e: ReactEventI): IO[Unit] =
$.props.model.toggleAll(e.target.checked)
def render: ReactElement = {
val todos = $.state.todos
val filteredTodos = todos filter $.props.currentFilter.accepts
val activeCount = todos count TodoFilter.Active.accepts
val completedCount = todos.length - activeCount
<.div(
<.h1("todos"),
<.header(
^.className := "header",
<.input(
^.className := "new-todo",
^.placeholder := "What needs to be done?",
^.onKeyDown ~~>? handleNewTodoKeyDown _,
^.autoFocus := true
)
),
todos.nonEmpty ?= todoList(filteredTodos, activeCount),
todos.nonEmpty ?= footer(activeCount, completedCount)
)
}
def todoList(filteredTodos: Seq[Todo], activeCount: Int): ReactElement =
<.section(
^.className := "main",
<.input(
^.className := "toggle-all",
^.`type` := "checkbox",
^.checked := activeCount == 0,
^.onChange ~~> toggleAll _
),
<.ul(
^.className := "todo-list",
filteredTodos.map(todo =>
CTodoItem(
onToggle = $.props.model.toggleCompleted(todo.id),
onDelete = $.props.model.delete(todo.id),
onStartEditing = startEditing(todo.id),
onUpdateTitle = updateTitle(todo.id),
onCancelEditing = editingDone(),
todo = todo,
isEditing = $.state.editing.contains(todo.id)
)
)
)
)
def footer(activeCount: Int, completedCount: Int): ReactElement =
CFooter(
filterLink = $.props.ctl.link,
onClearCompleted = $.props.model.clearCompleted,
currentFilter = $.props.currentFilter,
activeCount = activeCount,
completedCount = completedCount
)
}
private val component = ReactComponentB[Props]("CTodoList")
/* state derived from the props */
.initialStateP(p => State(p.model.todos, None))
.backend(Backend)
.render(_.backend.render)
/**
* Makes the component subscribe to events coming from the model.
* Unsubscription on component unmount is handled automatically.
* The last function is the actual event handling, in this case
* we just overwrite the whole list in `state`.
*/
.configure(Listenable.installIO((p: Props) => p.model, ($, todos: Seq[Todo]) => $.modStateIO(_.copy(todos = todos))))
/**
* Optimization where we specify whether the component can have changed.
* In this case we avoid comparing model and routerConfig, and only do
* reference checking on the list of todos.
*
* The implementation of the «equality» checks are in the Reusability
* typeclass instances for `State` and `Props` at the top of the file.
*
* To understand how things are redrawn, change `shouldComponentUpdate` for
* either `shouldComponentUpdateWithOverlay` or `shouldComponentUpdateAndLog`
*/
.configure(Reusability.shouldComponentUpdate)
/**
* For performance reasons its important to only call `build` once for each component
*/
.build
def apply(model: TodoModel, currentFilter: TodoFilter)(ctl: RouterCtl[TodoFilter]) =
component(Props(ctl, model, currentFilter))
}
package todomvc
import japgolly.scalajs.react._
import japgolly.scalajs.react.extra.router2._
import org.scalajs.dom
import scala.scalajs.js.JSApp
import scala.scalajs.js.annotation.JSExport
import scalaz.std.list._
import scalaz.syntax.foldable._
object Main extends JSApp {
val baseUrl = BaseUrl(dom.window.location.href.takeWhile(_ != '#'))
val routerConfig: RouterConfig[TodoFilter] = RouterConfigDsl[TodoFilter].buildConfig { dsl =>
import dsl._
/* how the application renders the list given a filter */
def filterRoute(s: TodoFilter): Rule = staticRoute("#/" + s.link, s) ~> renderR(CTodoList(model, s))
/** Combine routes for all filters using the Monoid instance for `Rule`.
* This could have been written out as
* `filterRoute(TodoFilter.All) | filterRoute(TodoFilter.Active) | filterRoute(TodoFilter.Completed)`
*/
val filterRoutes: Rule = (TodoFilter.values map filterRoute).suml
/* build a final RouterConfig with a default page */
filterRoutes.notFound(redirectToPage(TodoFilter.All)(Redirect.Replace))
}
/* instantiate model and restore todos */
val model = new TodoModel(Storage(dom.ext.LocalStorage, "todos-scalajs-react"))
model.restorePersisted.foreach(_.unsafePerformIO())
/** The router is itself a React component, which at this point is not mounted (U-suffix) */
val router: ReactComponentU[Unit, Resolution[TodoFilter], Any, TopNode] =
Router(baseUrl, routerConfig.logToConsole)()
/**
* Main entry point, which the sbt plugin finds and makes the browser run.
*
* Takes the unmounted router component and gives to React,
* will render into the first element with `todoapp` class
*/
@JSExport
override def main() =
React.render(router, dom.document.getElementsByClassName("todoapp")(0))
}
package todomvc
import org.scalajs.dom
import upickle.default._
import scala.util.{Failure, Success, Try}
case class Storage(storage: dom.ext.Storage, namespace: String) {
def store[T: Writer](data: T) =
storage(namespace) = write(data)
def load[T: Reader]: Option[T] =
Try(storage(namespace) map read[T]) match {
case Success(Some(t)) => Some(t)
case Success(None) => None
case Failure(th) =>
dom.console.error(s"Got invalid data ${th.getMessage}")
None
}
}
package todomvc
import japgolly.scalajs.react.extra.Broadcaster
import scala.language.postfixOps
import scalaz.effect.IO
class TodoModel(storage: Storage) extends Broadcaster[Seq[Todo]] {
private object State {
var todos = Seq.empty[Todo]
def mod(f: Seq[Todo] => Seq[Todo]): IO[Unit] =
IO {
val newTodos = f(todos)
todos = newTodos
storage.store(newTodos)
broadcast(newTodos)
}
def modOne(Id: TodoId)(f: Todo => Todo): IO[Unit] =
mod(_.map {
case existing@Todo(Id, _, _) => f(existing)
case other => other
})
}
def restorePersisted =
storage.load[Seq[Todo]].map(existing => State.mod(_ ++ existing))
def addTodo(title: Title): IO[Unit] =
State.mod(_ :+ Todo(TodoId.random, title, isCompleted = false))
def clearCompleted: IO[Unit] =
State.mod(_.filterNot(_.isCompleted))
def delete(id: TodoId): IO[Unit] =
State.mod(_.filterNot(_.id == id))
def todos: Seq[Todo] =
State.todos
def toggleAll(checked: Boolean): IO[Unit] =
State.mod(_.map(_.copy(isCompleted = checked)))
def toggleCompleted(id: TodoId): IO[Unit] =
State.modOne(id)(old => old.copy(isCompleted = !old.isCompleted))
def update(id: TodoId, text: Title): IO[Unit] =
State.modOne(id)(_.copy(title = text))
}
package todomvc
import java.util.UUID
case class TodoId(id: UUID)
object TodoId {
def random = new TodoId(UUID.randomUUID)
}
case class Title(value: String) {
def editable = UnfinishedTitle(value)
}
case class UnfinishedTitle(value: String) {
def validated: Option[Title] = Option(value.trim).filterNot(_.isEmpty).map(Title)
}
case class Todo(id: TodoId, title: Title, isCompleted: Boolean)
sealed abstract class TodoFilter(val link: String, val title: String, val accepts: Todo => Boolean)
object TodoFilter {
object All extends TodoFilter("", "All", _ => true)
object Active extends TodoFilter("active", "Active", !_.isCompleted)
object Completed extends TodoFilter("completed", "Completed", _.isCompleted)
def values = List[TodoFilter](All, Active, Completed)
}
......@@ -164,6 +164,9 @@
<li class="routing">
<a href="examples/reagent/" data-source="https://reagent-project.github.io/" data-content="Reagent provides a minimalistic interface between ClojureScript and React.">Reagent</a>
</li>
<li class="routing">
<a href="examples/scalajs-react/" data-source="https://github.com/japgolly/scalajs-react" data-content="Facebook's React on Scala.js.">Scala.js + React</a>
</li>
</ul>
</div>
<div class="js-app-list" data-app-list="labs">
......
......@@ -1644,6 +1644,9 @@
}, {
"name": "React & Backbone.js",
"url": "examples/react-backbone"
}, {
"name": "Scala.js & React",
"url": "examples/scalajs-react"
}],
"link_groups": [{
"heading": "Official Resources",
......@@ -1813,6 +1816,46 @@
}]
}]
},
"scalajs-react": {
"name": "scalajs-react",
"description": "Facebook's React on Scala.js.",
"homepage": "github.com/japgolly/scalajs-react/",
"examples": [{
"name": "Example",
"url": "examples/scalajs-react"
}, {
"name": "React",
"url": "examples/react"
}, {
"name": "React & Backbone.js",
"url": "examples/react-backbone"
}],
"link_groups": [{
"heading": "Official Resources",
"links": [{
"name": "Scalajs-React documentation",
"url": "https://github.com/japgolly/scalajs-react/"
}, {
"name": "Scala",
"url": "https://www.scala-lang.org/"
}, {
"name": "Scala.js",
"url": "http://www.scala-js.org/"
}, {
"name": "React tutorial",
"url": "https://facebook.github.io/react/docs/tutorial.html"
}]
}, {
"heading": "Community",
"links": [{
"name": "Scala-React Gitter",
"url": "https://gitter.im/japgolly/scalajs-react"
}, {
"name": "Scala on Stack Overflow",
"url": "https://stackoverflow.com/questions/tagged/scala"
}]
}]
},
"serenadejs": {
"name": "Serenade.js",
"description": "Serenade.js is a client side framework built on the MVC pattern. It makes it simple to create rich client side applications by freeing you from having to keep the DOM up to date with your data through powerful data bindings.",
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment