├── .gitignore ├── .rspec ├── .travis.yml ├── Cakefile ├── ChangeLog.md ├── Gemfile ├── LICENSE ├── README.md ├── bower.json ├── evil-blocks-rails.gemspec ├── lib ├── evil-blocks-debug.js ├── evil-blocks-rails.rb └── evil-blocks.js ├── logo.svg ├── package.json └── test ├── blocks.coffee ├── mocha.js ├── mocha.opts └── slim_spec.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *~ 3 | 4 | node_modules 5 | 6 | build 7 | pkg 8 | *.gem 9 | 10 | Gemfile.lock 11 | .bundle 12 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation --colour 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - "5" 5 | before_install: 6 | - rvm install ruby-2.3.0 7 | - rvm use 2.3.0 8 | - bundler install 9 | script: 10 | - rspec test/slim_spec.rb 11 | - npm test 12 | -------------------------------------------------------------------------------- /Cakefile: -------------------------------------------------------------------------------- 1 | fs = require('fs-extra') 2 | url = require('url') 3 | exec = require('child_process').exec 4 | http = require('http') 5 | path = require('path') 6 | 7 | project = 8 | 9 | package: -> 10 | JSON.parse(fs.readFileSync('package.json')) 11 | 12 | name: -> 13 | @package().name 14 | 15 | version: -> 16 | @package().version 17 | 18 | tests: -> 19 | fs.readdirSync('test/') 20 | .filter (i) -> i.match /\.coffee$/ 21 | .map (i) -> "test/#{i}" 22 | 23 | libs: -> 24 | fs.readdirSync('lib/').sort().reverse() 25 | .filter (i) -> i.indexOf('.js') != -1 26 | .map (i) -> "lib/#{i}" 27 | 28 | title: -> 29 | capitalize = (s) -> s[0].toUpperCase() + s[1..-1] 30 | @name().split('-').map( (i) -> capitalize(i) ).join(' ') 31 | 32 | mocha = 33 | 34 | template: """ 35 | 36 | 37 | 38 | #title# Tests 39 | 40 | #system# 41 | 47 | #libs# 48 | #tests# 49 | 50 |
51 |
52 | 53 | 54 | """ 55 | 56 | html: -> 57 | @render @template, 58 | system: @system() 59 | libs: @scripts project.libs() 60 | tests: @scripts project.tests() 61 | title: project.title() 62 | 63 | render: (template, params) -> 64 | html = template 65 | for name, value of params 66 | html = html.replace("##{name}#", value.replace(/\$/g, '$$$$')) 67 | html 68 | 69 | scripts: (files) -> 70 | files.map( (i) -> "" ).join("\n ") 71 | 72 | system: -> 73 | @scripts ['node_modules/jquery/dist/jquery.js', 74 | 'node_modules/should/should.js', 75 | 'node_modules/mocha/mocha.js'] 76 | 77 | task 'server', 'Run test server', -> 78 | coffee = require('coffee-script') 79 | 80 | server = http.createServer (req, res) -> 81 | pathname = url.parse(req.url).pathname 82 | 83 | if pathname == '/' 84 | res.writeHead 200, 'Content-Type': 'text/html' 85 | res.write mocha.html() 86 | 87 | else if pathname == '/style.css' 88 | res.writeHead 200, 'Content-Type': 'text/css' 89 | res.write fs.readFileSync('node_modules/mocha/mocha.css') 90 | 91 | else if fs.existsSync('.' + pathname) 92 | file = fs.readFileSync('.' + pathname).toString() 93 | if pathname.match(/\.coffee$/) 94 | file = coffee.compile(file) 95 | if pathname.match(/\.(js|coffee)$/) 96 | res.writeHead 200, 'Content-Type': 'application/javascript' 97 | res.write file 98 | 99 | else 100 | res.writeHead 404, 'Content-Type': 'text/plain' 101 | res.write 'Not Found' 102 | res.end() 103 | 104 | server.listen 8000 105 | process.stdout.write("Open http://localhost:8000/\n") 106 | 107 | task 'clean', 'Remove all generated files', -> 108 | fs.removeSync('pkg/') if fs.existsSync('pkg/') 109 | for file in fs.readdirSync('./') 110 | fs.removeSync(file) if file.match(/\.gem$/) 111 | 112 | task 'min', 'Create minimized version of library', -> 113 | uglify = require('uglify-js') 114 | 115 | invoke('clean') 116 | fs.mkdirsSync('pkg/') 117 | 118 | for file in project.libs() 119 | name = file.replace(/^lib\//, '').replace(/\.js$/, '') 120 | fs.copySync(file, "pkg/#{name}-#{project.version()}.min.js") 121 | 122 | packages = fs.readdirSync('pkg/').filter( (i) -> i.match(/\.js$/) ) 123 | for file in packages 124 | min = uglify.minify('pkg/' + file) 125 | fs.writeFileSync('pkg/' + file, min.code) 126 | -------------------------------------------------------------------------------- /ChangeLog.md: -------------------------------------------------------------------------------- 1 | ## 0.7 (Ranger Baker, 28th January 1951) 2 | * Rename `evil-blocks.debug` to `evil-blocks-debug` for Sprockets 3 support. 3 | 4 | ## 0.6.4 5 | * Add Sprockets 3 support. 6 | 7 | ## 0.6.3 8 | * Add Slim 3.0 support (by Andrey Krivko). 9 | 10 | ## 0.6.2 11 | * Fix IE 8 support (by Dmitry Klimensky). 12 | * Fix event name in debugger (by Andrey Krivko). 13 | 14 | ## 0.6.1 15 | * Fix debugger scope (by Andrey Krivko) 16 | 17 | ## 0.6 (Ranger Able, 27th January 1951) 18 | * Add filters, which process block object before init was called. 19 | * Most build-in features was moved to filter to be disableable. 20 | * Listener `load on window` will call immediately, if page was already loaded. 21 | 22 | ## 0.5.1 23 | * Fix block vitalizing, when multiple blocks was binded to same DOM node. 24 | 25 | ## 0.5 (RDS-1, 29th August 1949) 26 | * Current event target was moved from first argument to `event.el`. 27 | * Inside finder was moved from `@(selector)` to `@$(selector)`. 28 | * Remove old function style API. 29 | * Add `@@block` alias. 30 | * Add debugger extension. 31 | * Vitalize blocks on next tick after page ready. 32 | * Don’t vitalize blocks twice. 33 | * Method `evil.block.vitalize()` calls on `document` by default. 34 | * Allow to use GitHub master in Bundler. 35 | * Add Bower support. 36 | 37 | ## 0.4.2 (Zebra, 14th May 1948) 38 | * Don’t listen bubbled events as block event. 39 | * Change license to MIT. 40 | 41 | ## 0.4.1 (Yoke, 30th April 1948) 42 | * Allow to listen body and window events from object style. 43 | 44 | ## 0.4 (X-Ray, 14th April 1948) 45 | * Add new object style. 46 | 47 | ## 0.3.2 (Helen of Bikini, 25th July 1946) 48 | * Fix searching elements inside several blocks (by Andrei Miroshnik). 49 | 50 | ## 0.3.1 (Gilda, 1st July 1946) 51 | * Fix in multiple block copies on one page (by Andrei Miroshnik). 52 | 53 | ## 0.3 (Fat Man, 9th August 1945) 54 | * Add shortcut to Slim to set data-role and class. 55 | * Run callback on every block copy in page. 56 | 57 | ## 0.2 (Little Boy, 6th August 1945) 58 | * Support non-Rails applications in gem. 59 | 60 | ## 0.1 (The Gadget, 16th July 1945) 61 | * Initial release. 62 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'slim' 4 | gem 'rspec' 5 | 6 | gemspec 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright 2013 Andrey Sitnik 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Evil Blocks [![Build Status](https://travis-ci.org/ai/evil-blocks.svg)](https://travis-ci.org/ai/evil-blocks) 2 | 3 | 4 | 5 | Evil Block is a tiny JS framework for web pages. It is based on 4 ideas: 6 | 7 | * **Split code to independent blocks.** “Divide and conquer” is always good idea. 8 | * **Blocks communicate by events.** Events is an easy and safe method 9 | to keep complicated dependencies between controls very clean. 10 | * **Separate JS and CSS.** You should only use classes for styles and bind JS 11 | by special attribute selectors. This way you can update your styles without 12 | fear to break any scripts. 13 | * **Try not to render on client.** 2-way data-binding looks very cool, 14 | but it has a [big price]. Most of web pages (unlike web applications) 15 | can render all HTML on server and use client rendering only in few places. 16 | Without rendering we can have incredibly clean code and architecture. 17 | 18 | See also [Evil Front], a pack of helpers for Ruby on Rails and Evil Blocks. 19 | 20 | Role aliases were taken from [Role.js]. Based on Pieces.js by [@chrome]. 21 | 22 | 23 | Sponsored by Evil Martians 24 | 25 | 26 | [Role.js]: https://github.com/kossnocorp/role 27 | [big price]: http://staal.io/blog/2014/02/05/2-way-data-binding-under-the-microscope/ 28 | [Evil Front]: https://github.com/ai/evil-front 29 | [@chrome]: https://github.com/chrome 30 | 31 | ## Quick Example 32 | 33 | Slim template: 34 | 35 | ```haml 36 | .todo-control@@todo 37 | ul@tasks 38 | 39 | - @tasks.each do |task| 40 | .task@task 41 | = task.name 42 | form@finishForm action="/tasks/#{ task.id }/finish" 43 | input type="submit" value="Finish" 44 | 45 | form@addForm action="/tasks/" 46 | input type="text" name="name" 47 | input type="submit" value="Add" 48 | ``` 49 | 50 | Block’s CoffeeScript: 51 | 52 | ```coffee 53 | evil.block '@@todo', 54 | 55 | ajaxSubmit: (e) -> 56 | e.preventDefault() 57 | 58 | form = e.el 59 | form.addClass('is-loading') 60 | 61 | $.ajax 62 | url: form.attr('action') 63 | data: form.serialize() 64 | complete: -> form.removeClass('is-loading') 65 | 66 | 'submit on @finishForm': (e) -> 67 | @ajaxSubmit(e).done -> 68 | e.el.closest("@task").addClass("is-finished") 69 | 70 | 'submit on @addForm': (e) -> 71 | e.preventDefault() 72 | @ajaxSubmit(e).done (newTaskHTML) -> 73 | @tasks.append(newTaskHTML) 74 | ``` 75 | 76 | ## Attributes 77 | 78 | If you use classes selectors in CSS and JS, your scripts will be depend 79 | on styles. If you change `.small-button` to `.big-button`, you must 80 | change all the button’s selectors in scripts. 81 | 82 | Separated scripts and styles are better, so Evil Blocks prefers to work with 83 | two HTML attributes to bind your JS: `data-block` (to define blocks) 84 | and `data-role` (to define elements inside block). 85 | 86 | ```html 87 |
88 | 90 |
91 | ``` 92 | 93 | Evil Blocks extends Slim and jQuery, so you can use shortcuts for these 94 | attributes: `@@block` and `@role`. For Haml you can use [Role Block Haml] gem 95 | to use the same shortcuts. 96 | 97 | ```haml 98 | @@todo 99 | ul@tasks 100 | ``` 101 | 102 | ```js 103 | $('@tasks') 104 | ``` 105 | 106 | With these attributes you can easily change interface style 107 | and be sure in scripts: 108 | 109 | ```haml 110 | .big-button@addButton 111 | ``` 112 | 113 | Of course, Evil Block doesn’t force you to write only these selectors. 114 | You can use any attributes, that you like. 115 | 116 | [Role Block Haml]: https://github.com/vladson/role_block_haml 117 | 118 | ## Blocks 119 | 120 | You should split your interface into independent controls and mark them 121 | with `data-block`: 122 | 123 | ```haml 124 | header@@header 125 | a.exit href="#" 126 | 127 | .todo-control@@todo 128 | ul.tasks 129 | 130 | .docs-page@@docs 131 | ``` 132 | 133 | Also you can vitalize your blocks in scripts with `evil.block` function: 134 | 135 | ```coffee 136 | evil.block '@@header', 137 | 138 | init: -> 139 | console.log('Vitalize', @block) 140 | ``` 141 | 142 | When a page was loaded Evil Blocks finds blocks by `@@header` selector 143 | (this is a shortcut for `[data-block=header]`) and calls `init` on every 144 | founded block. So, if your page contains two headers, `init` will be called 145 | twice with different `@block`’s. 146 | 147 | The `@block` property will contain a jQuery node of current block. 148 | You can search elements inside of current block with `@$(selector)` method: 149 | 150 | ```coffee 151 | evil.block '@@docs', 152 | 153 | init: -> 154 | @$('a').attr(target: '_blank') # Open all links inside docs in new tab 155 | # Same as @block.find('a') 156 | ``` 157 | 158 | You can add any methods and properties to your block class: 159 | 160 | ```coffee 161 | evil.block '@@gallery', 162 | current: 0 163 | 164 | showPhoto: (num) -> 165 | @$('img').hide(). 166 | filter("eql(#{ num })").show() 167 | 168 | init: -> 169 | @showPhoto(@current) 170 | ``` 171 | 172 | Evil Blocks will automatically create properties with jQuery nodes 173 | for every element inside of a block with `data-role` attribute: 174 | 175 | ```haml 176 | .todo-control@@todo 177 | ul.tasks@tasks 178 | ``` 179 | 180 | ```coffee 181 | evil.block '@@todo', 182 | 183 | addTask: (task) -> 184 | @tasks.append(task) 185 | ``` 186 | 187 | If you add new HTML with AJAX, you can vitalize new blocks with 188 | `evil.block.vitalize()`. This function will vitalize only new blocks in 189 | a document. 190 | 191 | ```coffee 192 | @sections.append(html) 193 | evil.block.vitalize() 194 | ``` 195 | 196 | ## Events 197 | 198 | You can bind listeners to events inside of a block with `events on selectors` 199 | method: 200 | 201 | ```coffee 202 | evil.block '@@todo', 203 | 204 | 'submit on @finishForm': -> 205 | # Event listener 206 | ``` 207 | 208 | A more difficult example: 209 | 210 | ```coffee 211 | evil.block '@@form', 212 | ajaxSearch: -> … 213 | 214 | 'change, keyup on input, select': (event) -> 215 | field = event.el() 216 | @ajaxSearch('Changed', field.val()) 217 | ``` 218 | 219 | Listener will receive a jQuery Event object as the first argument. 220 | Current element (`this` in jQuery listeners) will be contained in `event.el` 221 | property. All listeners are delegated on current block, so `click on @button` 222 | is equal to `@block.on 'click', '@button', ->`. 223 | 224 | You should prevent default event behavior with `event.preventDefault()`, 225 | `return false` will not do anything in block’s listeners. I recommend 226 | [evil-front/links] to prevent default behavior in any links with `href="#"` 227 | to clean your code. 228 | 229 | You can also bind events on body and window: 230 | 231 | ```coffee 232 | evil.blocks '@@docs', 233 | recalcMenu: -> … 234 | openPage: -> … 235 | 236 | init: -> 237 | @recalcMenu() 238 | 239 | 'resize on window': -> 240 | @recalcMenu() 241 | 242 | 'hashchange on window': -> 243 | @openPage(location.hash) 244 | ``` 245 | 246 | Listener `load on window` will execute immediately, if window is already loaded. 247 | 248 | [evil-front/links]: https://github.com/ai/evil-front/blob/master/evil-front/lib/assets/javascripts/evil-front/links.js 249 | 250 | ## Blocks Communications 251 | 252 | Blocks should communicate via custom jQuery events. You can bind an event 253 | listener to a block node with `on events` method: 254 | 255 | ```coffee 256 | evil.block '@@slideshow', 257 | nextSlide: -> … 258 | 259 | 'on play': -> 260 | @timer = setInterval(=> @nextSlide, 5000) 261 | 262 | 'on stop': -> 263 | clearInterval(@timer) 264 | 265 | evil.block '@@video', 266 | 267 | 'click on @fullscreenButton': -> 268 | $('@@slideshow').trigger('stop') 269 | ``` 270 | 271 | If you want to use broadcast messages, you can use custom events on body: 272 | 273 | ```coffee 274 | evil.block '@@callUs', 275 | 276 | 'change-city on body': (e, city) -> 277 | @phoneNumber.text(city.phone) 278 | 279 | evil.block '@@cityChanger', 280 | getCurrentCity: -> … 281 | 282 | 'change on @citySelect': -> 283 | $('body').trigger('change-city', @getCurrentCity()) 284 | ``` 285 | 286 | ## Rendering 287 | 288 | If you render on the client and on the server-side, you must repeat helpers, 289 | i18n, templates. Client rendering requires a lot of libraries and architecture. 290 | 2-way data binding looks cool, but has a very [big price] in performance, 291 | templates, animation and overengeniring. 292 | 293 | If you develop a web page (not a web application with offline support, etc), 294 | server-side rendering will be more useful. Users will see your interface 295 | imminently, search engines will index your content and your code will be much 296 | simple and clear. 297 | 298 | In most of cases you can avoid client-side rendering. If you need to add a block 299 | with JS, you can render it hidden to page HTML and show it in right time: 300 | 301 | ```coffee 302 | evil.block '@@comment', 303 | 304 | 'click on @addCommentButton': -> 305 | @newCommentForm.slideDown() 306 | ``` 307 | 308 | If a user changes some data and you need to update the view, you anyway need 309 | to send a request to save the new data on a server. Just ask the server 310 | to render a new view. For example, on a new comment server can return 311 | new comment HTML: 312 | 313 | ```coffee 314 | evil.block '@@comment', 315 | 316 | 'submit on @addCommentForm': -> 317 | $.post '/comments', @addCommentForm.serialize(), (newComment) -> 318 | @comments.append(newComment) 319 | ``` 320 | 321 | But, of course, some cases require client-side rendering. Evil Blocks only 322 | recommends to do it on the server side, but not force you: 323 | 324 | ```coffee 325 | evil.block '@@comment', 326 | 327 | 'change, keyup on @commentField', -> 328 | html = JST['comment'](text: @commentField.text()) 329 | @preview.html(html) 330 | ``` 331 | 332 | [big price]: http://staal.io/blog/2014/02/05/2-way-data-binding-under-the-microscope/ 333 | 334 | ## Debug 335 | 336 | Evil Blocks contains a debug extension, which logs all the events inside blocks. 337 | To enable it, just load `evil-blocks-debug.js`. For example, in Rails: 338 | 339 | ```haml 340 | - if Rails.env.development? 341 | = javascript_include_tag 'evil-blocks-debug' 342 | ``` 343 | 344 | ## Extensions 345 | 346 | Evil Blocks has a tiny core. It only finds blocks via selectors, 347 | sets the `@block` property and calls the `init` method. Any other features 348 | (like event bindings or `@$()` method) are created by filters 349 | and can be disabled or replaced. 350 | 351 | Before calling `init`, Evil Blocks processes an object through the filters list 352 | in `evil.block.filters`. A filter accepts an object as its first argument and 353 | an unique class ID as the second. It can find some properties inside of 354 | the object, work with block DOM nodes and add/remove some object properties. 355 | If filter returns `false`, Evil Blocks will stop block vitalizing 356 | and will not call the `init` method. 357 | 358 | Default filters: 359 | 360 | 1. **Don’t vitalize same DOM node twice.** It returns `false` if a block 361 | was already initialized with a given ID. 362 | 2. **Add `@$()` method.** It adds a shortcut find method to an object. 363 | 3. **Add shortcuts to `@element`.** It adds properties for all children with 364 | `data-role` attribute. 365 | 4. **Bind block events.** Find, bind listeners and remove all the methods with 366 | a name like `on event`. 367 | 5. **Smarter window load listener.** Run `load on window` listener immediately, 368 | if window is already loaded. 369 | 6. **Bind window and body events.** Find, bind listeners and remove all 370 | the methods with a name like `event on window` or `event on body`. 371 | 7. **Bind elements events.** Find, bind listeners and remove all the methods 372 | with a name like `event on child`. 373 | 374 | You can add you own filter to `evil.block.filters`. Most filters should be added 375 | after first filter to not been called on already initialized blocks. 376 | 377 | Let’s write filter, which will initialize blocks only when they become 378 | to be visible. 379 | 380 | ```coffee 381 | filter = (obj) -> 382 | if not obj.block.is(':visible') 383 | # Check for visibility every 100 ms 384 | # and recall vitalizing if block become visible 385 | checking = -> 386 | evil.block.vitalize(obj.block) if obj.block.is(':visible') 387 | setTimeout(checking, 100); 388 | 389 | # Disable block initializing 390 | return false 391 | 392 | # Add filter to list 393 | evil.block.filters.splice(0, 0, filter) 394 | ``` 395 | 396 | With the filters you can change Evil Blocks logic, add some new shortcuts 397 | or features like mixins. 398 | 399 | Also you can remove any default filters from `evil.block.filters`. For example, 400 | you can create properties for `data-role` children only from some white list. 401 | 402 | But Filters API is still unstable and you should be careful on major updates. 403 | 404 | ## Modules 405 | 406 | If your blocks have same behavior, you can create a module-block 407 | and set multiple blocks on the same tag: 408 | 409 | ```haml 410 | @popup@@closable 411 | a@closeLink href="#" 412 | ``` 413 | 414 | ```coffee 415 | evil.block '@@closable', 416 | 417 | 'click on @closeLink': -> 418 | @block.trigger('close') 419 | 420 | evil.block '@@popup', 421 | 422 | 'on close': -> 423 | @block.removeClass('is-open') 424 | ``` 425 | 426 | If you want to use same methods inside of multiple block, you can create 427 | an inject-function: 428 | 429 | ```coffee 430 | fancybox = (obj) -> 431 | for name, value of fancybox.module 432 | obj[name] = value 433 | # Initializer code 434 | 435 | fancybox.module = 436 | openInFancybox: (node) -> 437 | 438 | evil.block '@@docs', 439 | 440 | init: -> 441 | fancybox(@) 442 | 443 | 'click on @showExampleButton': -> 444 | @openInFancybox(@example) 445 | ``` 446 | 447 | ## Install 448 | 449 | ### Ruby on Rails 450 | 451 | Add `evil-block-rails` gem to `Gemfile`: 452 | 453 | ```ruby 454 | gem "evil-blocks-rails" 455 | ``` 456 | 457 | Load `evil-blocks.js` in your script: 458 | 459 | ```js 460 | //= require evil-blocks 461 | ``` 462 | 463 | If you use Rails 3 on Heroku, you may need 464 | [some hack](https://github.com/ai/evil-blocks/issues/17). 465 | 466 | ### Ruby 467 | 468 | If you use Sinatra or other non-Rails framework you can add Evil Blocks path 469 | to Sprockets environment: 470 | 471 | ```ruby 472 | EvilBlocks.install(sprockets) 473 | ``` 474 | 475 | And change Slim options to support `@@block` and `@rule` shortcuts: 476 | 477 | ```ruby 478 | EvilBlocks.install_to_slim! 479 | ``` 480 | 481 | Then just load `evil-blocks.js` in your script: 482 | 483 | ```js 484 | //= require evil-blocks 485 | ``` 486 | 487 | ### Others 488 | 489 | Add file `lib/evil-blocks.js` to your project. 490 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "evil-blocks", 3 | "version": "07.0", 4 | "homepage": "https://github.com/ai/evil-blocks", 5 | "authors": ["Andrey A.I. Sitnik "], 6 | "description": "Tiny JS framework for web pages to split your app to independent blocks", 7 | "keywords": ["framework", "blocks", "events"], 8 | "license": "MIT", 9 | "main": [ 10 | "lib/evil-blocks.js" 11 | ], 12 | "dependencies": { 13 | "jquery": ">= 1.6.0" 14 | }, 15 | "ignore": [ 16 | "package.json", 17 | "*.gemspec", 18 | "Cakefile", 19 | "Gemfile*", 20 | "test", 21 | "*.rb", 22 | ".*" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /evil-blocks-rails.gemspec: -------------------------------------------------------------------------------- 1 | require 'pathname' 2 | 3 | package = Pathname(__FILE__).dirname.join('package.json').read 4 | version = package.match(/"version": "([\d\.]+)",/)[1] 5 | 6 | Gem::Specification.new do |s| 7 | s.platform = Gem::Platform::RUBY 8 | s.name = 'evil-blocks-rails' 9 | s.version = version 10 | s.summary = 'Tiny JS framework for web pages to split your app ' + 11 | 'to independent blocks' 12 | 13 | s.files = ['lib/evil-blocks-debug.js', 'lib/evil-blocks.js', 14 | 'lib/evil-blocks-rails.rb', 15 | 'LICENSE', 'README.md', 'ChangeLog.md'] 16 | s.extra_rdoc_files = ['LICENSE', 'README.md', 'ChangeLog.md'] 17 | s.require_path = 'lib' 18 | 19 | s.author = 'Andrey Sitnik' 20 | s.email = 'andrey@sitnik.ru' 21 | s.homepage = 'https://github.com/ai/evil-blocks' 22 | s.license = 'MIT' 23 | 24 | s.add_dependency 'sprockets', '>= 2' 25 | end 26 | -------------------------------------------------------------------------------- /lib/evil-blocks-debug.js: -------------------------------------------------------------------------------- 1 | ;(function () { 2 | "use strict"; 3 | 4 | var log = function () { 5 | if ( !console || !console.log ) { 6 | return; 7 | } 8 | console.log.apply(console, arguments); 9 | }; 10 | 11 | if ( !window.evil || !window.evil.block ) { 12 | log("You should include evil-blocks-debug.js after evil-blocks.js"); 13 | return; 14 | } 15 | 16 | var logger = function (obj) { 17 | for ( var name in obj ) { 18 | if ( name.indexOf('on ') == -1 ) continue; 19 | 20 | var parts = name.split('on '); 21 | var event = parts[0] ? parts[0] : parts[1]; 22 | 23 | var callback = obj[name]; 24 | 25 | (function(event, callback){ 26 | obj[name] = function (e) { 27 | var source = e.el ? e.el[0] : this.block[0]; 28 | var messages = ['Event "' + event + '" on', source]; 29 | 30 | var params = Array.prototype.slice.call(arguments, 1); 31 | if ( params.length > 0 ) { 32 | messages.push('with params'); 33 | messages = messages.concat(params); 34 | } 35 | 36 | log.apply(this, messages); 37 | callback.apply(this, arguments); 38 | } 39 | })(event, callback); 40 | } 41 | }; 42 | 43 | evil.block.filters.splice(2, 0, logger); 44 | })(); 45 | -------------------------------------------------------------------------------- /lib/evil-blocks-rails.rb: -------------------------------------------------------------------------------- 1 | module EvilBlocks 2 | # Change Slim options to support @data-role shortcut. 3 | def self.install_to_slim! 4 | # Add @data-role alias to Slim. 5 | # 6 | # Copy from role-rails by Sasha Koss. 7 | # https://github.com/kossnocorp/role-rails 8 | shortcut = Slim::Parser.options[:shortcut] 9 | shortcut['@'] = { attr: 'data-role' } 10 | shortcut['@@'] = { attr: 'data-block' } 11 | Slim::Engine.options[:merge_attrs]['data-role'] = ' ' 12 | Slim::Engine.options[:merge_attrs]['data-block'] = ' ' 13 | end 14 | 15 | # Add assets paths to standalone Sprockets environment. 16 | def self.install(sprockets) 17 | sprockets.paths << Pathname(__FILE__).dirname 18 | end 19 | 20 | if defined? ::Rails 21 | class Engine < ::Rails::Engine 22 | initializer 'evil-blocks' do |app| 23 | EvilBlocks.install(app.config.assets) 24 | EvilBlocks.install_to_slim! if defined?(Slim::Parser) 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/evil-blocks.js: -------------------------------------------------------------------------------- 1 | (function(factory) { 2 | "use strict"; 3 | 4 | if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { 5 | module.exports = factory(require('jquery'), window) 6 | } else { 7 | /* 8 | * Evil namespace. Also can be used in Evil Front. 9 | */ 10 | if ( !window.evil ) window.evil = {}; 11 | window.evil.block = factory(window.$, window) 12 | } 13 | }(function ($, window) { 14 | // Helpers 15 | var $window = $(window); 16 | 17 | // Clone object 18 | var clone = function (origin) { 19 | var cloned = { }; 20 | for ( var name in origin ) { 21 | cloned[name] = origin[name]; 22 | } 23 | return cloned; 24 | }; 25 | 26 | // Is string ends with substring. 27 | var endsWith = function (string, substring) { 28 | return string.substr(-substring.length) === substring; 29 | }; 30 | 31 | /* 32 | * Add `@data-role` alias to jQuery. 33 | * 34 | * Copy from jquery.role by Sasha Koss https://github.com/kossnocorp/role 35 | */ 36 | 37 | var rewriteSelector = function (context, name, pos) { 38 | var original = context[name]; 39 | if ( !original ) return; 40 | 41 | context[name] = function () { 42 | arguments[pos] = arguments[pos].replace( 43 | /@@([\w\u00c0-\uFFFF\-]+)/g, '[data-block~="$1"]'); 44 | arguments[pos] = arguments[pos].replace( 45 | /@([\w\u00c0-\uFFFF\-]+)/g, '[data-role~="$1"]'); 46 | return original.apply(context, arguments); 47 | }; 48 | 49 | $.extend(context[name], original); 50 | }; 51 | 52 | rewriteSelector($, 'find', 0); 53 | rewriteSelector($, 'multiFilter', 0); 54 | rewriteSelector($.find, 'matchesSelector', 1); 55 | rewriteSelector($.find, 'matches', 0); 56 | 57 | // Find selector inside base DOM node and cretae class for it. 58 | var find = function (base, id, selector, klass) { 59 | var blocks = $().add( base.filter(selector) ). 60 | add( base.find(selector) ); 61 | 62 | if ( blocks.length == 0 ) return; 63 | 64 | var objects = []; 65 | 66 | blocks.each(function (_, node) { 67 | var block = $(node); 68 | 69 | var obj = clone(klass); 70 | obj.block = block; 71 | 72 | for ( var i = 0; i < evilBlock.filters.length; i++ ) { 73 | var stop = evilBlock.filters[i](obj, id); 74 | if ( stop === false ) return; 75 | } 76 | 77 | objects.push(obj) 78 | }); 79 | 80 | return function () { 81 | for ( var i = 0; i < objects.length; i++ ) { 82 | if (objects[i].init) objects[i].init(); 83 | } 84 | }; 85 | }; 86 | 87 | // If onready event was already happend. 88 | var ready = false; 89 | 90 | // If onload event was already happend. 91 | var loaded = false; 92 | $window.on('load', function (event) { 93 | loaded = event; 94 | }); 95 | 96 | // Latest block ID 97 | var lastBlock = 0; 98 | 99 | /** 100 | * Create object for every `selector` finded in page and call their 101 | * `init` method. 102 | * 103 | * evilBlock '.user-page .buttons', 104 | * init: -> 105 | * @gallery.fotorama() 106 | * delete: -> 107 | * @deleteForm.submit -> 108 | * $('user-status').trigger('deleted') 109 | * 'click on @deleleLink': (e) -> 110 | * e.el.addClass('is-loading') 111 | * delete() 112 | * 'on update': -> 113 | * location.reload() 114 | * 115 | * Every `data-role="aName"` in HTML will create in object `aName` property 116 | * with jQuery node. 117 | * 118 | * To bind delegate listener just create `EVENT on SELECTOR` method. 119 | * In first argument it will receive jQuery node of `e.currentTarget`, 120 | * second will be event object and others will be parameters. 121 | * 122 | * To communicate between blocks, just trigget custom events. To receive 123 | * events from another blocks, create `on EVENT` method. Event object will 124 | * be on first argument here. 125 | * 126 | * Block node will be in `@block` property and you can search only inside 127 | * block by `@(selector)` method. 128 | * 129 | * If your block contrain only `init` method, you can use shortcut: 130 | * 131 | * evilBlock '.block', -> 132 | * # init method 133 | */ 134 | var evilBlock = function (selector, klass) { 135 | lastBlock += 1; 136 | var id = lastBlock; 137 | 138 | if ( typeof(klass) == 'function' ) { 139 | klass = { init: klass }; 140 | } 141 | 142 | evilBlock.defined.push([id, selector, klass]); 143 | 144 | if ( ready ) { 145 | var init = find($(document), id, selector, klass); 146 | if ( init ) init(); 147 | } 148 | }; 149 | 150 | /** 151 | * Vitalize all current blocks inside base. You must call it on every 152 | * new content from AJAX. 153 | * 154 | * 'on click on @load': -> 155 | * $.get '/comments', (comments) => 156 | * evilBlock.vitalize $(comments).applyTo(@comments) 157 | */ 158 | evilBlock.vitalize = function (base) { 159 | if ( base ) { 160 | base = $(base); 161 | } else { 162 | base = $(document); 163 | } 164 | 165 | var inits = []; 166 | for ( var i = 0; i < evilBlock.defined.length; i++ ) { 167 | var define = evilBlock.defined[i]; 168 | inits.push( find(base, define[0], define[1], define[2]) ); 169 | } 170 | 171 | for ( var i = 0; i < inits.length; i++ ) { 172 | if ( inits[i] ) inits[i](); 173 | } 174 | }; 175 | 176 | /** 177 | * Evil blocks list. 178 | */ 179 | evilBlock.defined = []; 180 | 181 | /** 182 | * Filters to process block object and add some extra functions 183 | * to Evil Blocks. For example, allow to write listeners. 184 | * 185 | * Filter will receive block object and unique class ID. 186 | * If filter return `false`, block will not be created. 187 | */ 188 | evilBlock.filters = []; 189 | 190 | var filters = evilBlock.filters; 191 | 192 | /** 193 | * Don’t vitalize already vitalized block. 194 | * 195 | * For better perfomance, it should be last filter. 196 | */ 197 | filters.push(function (obj, id) { 198 | var ids = obj.block.data('evil-blocks'); 199 | if ( !ids ) { 200 | ids = []; 201 | } else if ( ids.indexOf(id) != -1 ) { 202 | return false; 203 | } 204 | ids.push(id); 205 | obj.block.data('evil-blocks', ids); 206 | }); 207 | 208 | /** 209 | * Create `this.$()` as alias for `this.block.find()` 210 | */ 211 | filters.push(function (obj) { 212 | obj.$ = function (subselector) { 213 | return obj.block.find(subselector); 214 | }; 215 | }); 216 | 217 | /** 218 | * Create properties for each element with `data-role`. 219 | */ 220 | filters.push(function (obj) { 221 | obj.block.find('[data-role]').each(function (_, el) { 222 | var roles = el.attributes['data-role'].value.split(' '); 223 | for ( var i = 0; i < roles.length; i++ ) { 224 | var role = roles[i]; 225 | if ( !obj[role] ) obj[role] = $(); 226 | if ( obj[role].jquery ) obj[role].push(el); 227 | } 228 | }); 229 | }); 230 | 231 | /** 232 | * Syntax sugar to listen block events. 233 | */ 234 | filters.push(function (obj) { 235 | for ( var name in obj ) { 236 | if ( name.substr(0, 3) != 'on ' ) continue; 237 | 238 | var events = name.substr(3); 239 | var callback = obj[name]; 240 | delete obj[name]; 241 | 242 | (function (events, callback) { 243 | obj.block.on(events, function (e) { 244 | if ( e.currentTarget == e.target ) { 245 | callback.apply(obj, arguments); 246 | } 247 | }); 248 | })(events, callback); 249 | } 250 | }); 251 | 252 | /** 253 | * Smart `load on window` listener, which fire immediately 254 | * if page was already loaded. 255 | */ 256 | filters.push(function (obj) { 257 | var name = 'load on window'; 258 | var callback = obj[name]; 259 | 260 | if ( !callback ) return; 261 | delete obj[name]; 262 | 263 | if ( loaded ) { 264 | setTimeout(function () { 265 | callback.call(obj, loaded); 266 | }, 1); 267 | } else { 268 | $window.on('load', function (event) { 269 | callback.call(obj, event); 270 | }); 271 | } 272 | }); 273 | 274 | /** 275 | * Syntax sugar to listen window and body events. 276 | */ 277 | filters.push(function (obj) { 278 | for ( var name in obj ) { 279 | var elem = false; 280 | if ( endsWith(name, 'on body') ) { 281 | elem = $('body'); 282 | } else if ( endsWith(name, 'on window') ) { 283 | elem = $window; 284 | } 285 | 286 | if ( !elem ) continue; 287 | 288 | var event = name.split(' on ')[0]; 289 | var callback = obj[name]; 290 | delete obj[name]; 291 | 292 | (function (elem, event, callback) { 293 | elem.on(event, function () { 294 | callback.apply(obj, arguments); 295 | }); 296 | })(elem, event, callback); 297 | } 298 | }); 299 | 300 | /** 301 | * Syntax sugar to listen element events. 302 | */ 303 | filters.push(function (obj) { 304 | for ( var name in obj ) { 305 | var parts = name.split(' on '); 306 | if ( !parts[1] ) continue; 307 | 308 | var callback = obj[name]; 309 | delete obj[name]; 310 | 311 | (function (parts, callback) { 312 | obj.block.on(parts[0], parts[1], function (e) { 313 | e.el = $(this); 314 | callback.apply(obj, arguments); 315 | }); 316 | })(parts, callback); 317 | } 318 | }); 319 | 320 | /* 321 | * Run all blocks on load. 322 | */ 323 | $(document).ready(function () { 324 | ready = true; 325 | evilBlock.vitalize(); 326 | }); 327 | 328 | return evilBlock 329 | })) 330 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "evil-blocks", 3 | "version": "0.7.0", 4 | "dependencies": { 5 | "jquery": ">= 2.1.0" 6 | }, 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/ai/evil-blocks.git" 11 | }, 12 | "devDependencies": { 13 | "coffee-script": "1.10.0", 14 | "uglify-js": "2.6.1", 15 | "fs-extra": "0.26.4", 16 | "should": "8.1.1", 17 | "mocha": "2.3.4", 18 | "jsdom": "7.2.2" 19 | }, 20 | "scripts": { 21 | "test": "mocha" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /test/blocks.coffee: -------------------------------------------------------------------------------- 1 | evilBlock = require('../lib/evil-blocks') 2 | body = (html) -> evilBlock.vitalize fixtures.html(html) 3 | fixtures = null 4 | 5 | describe 'evilBlock', -> 6 | before -> fixtures = $('#fixtures') 7 | 8 | afterEach -> 9 | fixtures.html('') 10 | evilBlock.defined = [] 11 | 12 | it 'adds role alias', -> 13 | body '' + 14 | '' + 15 | '' 16 | $('@roleTest').length.should.eql(1) 17 | $('@multi').length.should.eql(2) 18 | 19 | it 'adds block alias', -> 20 | body '' + 21 | '' + 22 | '' 23 | $('@@roleTest').length.should.eql(1) 24 | $('@@multi').length.should.eql(2) 25 | 26 | describe '.vitalize()', -> 27 | 28 | it 'calls vitalize on document by default', -> 29 | called = false 30 | evilBlock '.page', -> 31 | called = true 32 | 33 | fixtures.html('') 34 | evilBlock.vitalize() 35 | 36 | called.should.be.true 37 | 38 | it 'calls vitalize on subnode', -> 39 | called = [] 40 | evilBlock '.page', -> 41 | called.push @block[0].tagName 42 | 43 | fixtures.html('') 44 | evilBlock.vitalize($('span')) 45 | 46 | called.should.eql ['B'] 47 | 48 | it 'accepts DOM nodes', -> 49 | called = [] 50 | evilBlock '.page', -> 51 | called.push @block[0].tagName 52 | 53 | fixtures.html('') 54 | evilBlock.vitalize($('#fixtures span')) 55 | 56 | called.should.eql ['B'] 57 | 58 | describe '()', -> 59 | 60 | it 'understands function as init', -> 61 | called = false 62 | evilBlock '.page', -> called = true 63 | 64 | body '' 65 | called.should.be.false 66 | 67 | body '' 68 | called.should.be.true 69 | 70 | it 'executes callback only if find selector', -> 71 | called = false 72 | evilBlock '.page', 73 | init: -> 74 | called = true 75 | 76 | body '' 77 | called.should.be.false 78 | 79 | body '' 80 | called.should.be.true 81 | 82 | it 'executes only once on same block', -> 83 | called = 0 84 | evilBlock '.page', 85 | init: -> 86 | called += 1 87 | 88 | body '' 89 | called.should.be.eql(1) 90 | 91 | evilBlock.vitalize fixtures 92 | called.should.be.eql(1) 93 | 94 | fixtures.append('') 95 | evilBlock.vitalize fixtures 96 | called.should.be.eql(2) 97 | 98 | it 'works with multiple blocks on same node', -> 99 | called = '' 100 | evilBlock '.page', 101 | init: -> 102 | called += '1' 103 | evilBlock '.page', 104 | init: -> 105 | called += '2' 106 | 107 | body '' 108 | called.should.be.eql('12') 109 | 110 | evilBlock.vitalize fixtures 111 | called.should.be.eql('12') 112 | 113 | it 'creates properties for each role', -> 114 | prop = false 115 | evilBlock '.page', 116 | init: -> 117 | prop = @roleName 118 | 119 | body '
' 120 | prop.is('@roleName').should.be.true 121 | 122 | it 'listens block events', -> 123 | burning = '' 124 | evilBlock '.page', 125 | 'on fire burn': (e, param) -> 126 | burning += ' ' + e.type + ' ' + param 127 | 'on ice': -> 128 | burning += ' ice' 129 | 130 | body '
' 131 | $('.page').trigger('fire', '1').trigger('burn', '2') 132 | burning.should.eql ' fire 1 burn 2' 133 | 134 | it 'checks source for block events', -> 135 | burning = '' 136 | evilBlock '.page', 137 | 'on fire': -> 138 | burning = '1' 139 | 140 | body '
' 141 | $('@a').trigger('fire') 142 | burning.should.eql('') 143 | 144 | it 'listens elements events', -> 145 | burning = '' 146 | evilBlock '.page', 147 | 'fire burn on @a, @b': (e, param) -> 148 | burning += ' ' + e.el.data('role') + ' ' + param 149 | 'ice on @a': -> 150 | burning += ' ice' 151 | 152 | body """ 153 |
154 | 155 |
156 | """ 157 | $('@a').trigger('fire', '1') 158 | $('@b').trigger('burn', '2') 159 | burning.should.eql ' a 1 b 2' 160 | 161 | it 'listens body events', -> 162 | burning = '' 163 | evilBlock '.page', 164 | 'fire on body': (e, param) -> 165 | burning = param 166 | 'ice on body': -> 167 | burning += 'ice' 168 | 169 | body '
' 170 | $('body').trigger('fire', '1') 171 | burning.should.eql '1' 172 | 173 | it 'listens body bubble events', -> 174 | burning = '' 175 | evilBlock '.page', 176 | 'fire on body': -> 177 | burning = '1' 178 | 179 | body '
' 180 | $('.page').trigger('fire') 181 | burning.should.eql '1' 182 | 183 | it 'listens window events', -> 184 | burning = '' 185 | evilBlock '.page', 186 | 'fire on window': (e, param) -> 187 | burning = param 188 | 189 | body '
' 190 | $(window).trigger('fire', '1') 191 | burning.should.eql '1' 192 | 193 | it 'fires event immedently if page already loaded', (done) -> 194 | burning = false 195 | evilBlock '.page', 196 | 'load on window': -> 197 | burning = true 198 | 199 | body '
' 200 | setTimeout( -> 201 | burning.should.be.true 202 | done() 203 | , 10) 204 | 205 | it 'finds inside', -> 206 | finded = false 207 | evilBlock '.page', 208 | init: -> 209 | finded = @$('b').text() 210 | 211 | body '
finded
' 212 | finded.should.eql 'finded' 213 | 214 | it 'has block property', -> 215 | block = [] 216 | evilBlock '.page', -> 217 | block.push @block 218 | 219 | body '
' 220 | 221 | block.length.should.eql(2) 222 | block[0].length.should.eql(1) 223 | block[0].is('.page').should.be.true 224 | block[1].length.should.eql(1) 225 | block[1].is('.page').should.be.true 226 | 227 | it 'calls init after all bindings', -> 228 | events = [] 229 | 230 | evilBlock '.a', 231 | init: -> 232 | $('.b').trigger('fire') 233 | 'on fire': -> 234 | events.push('a') 235 | 236 | evilBlock '.b', 237 | init: -> 238 | $('.a').trigger('fire') 239 | 'on fire': -> 240 | events.push('b') 241 | 242 | body '
' 243 | events.should.eql ['b', 'a'] 244 | 245 | it 'prevents to override properties by elements', -> 246 | value = null 247 | evilBlock '.page', 248 | one: 1 249 | 'on fire': -> 250 | value = @one 251 | 252 | body '
' 253 | 254 | $('.page').trigger('fire') 255 | value.should.eql(1) 256 | -------------------------------------------------------------------------------- /test/mocha.js: -------------------------------------------------------------------------------- 1 | jsdom = require('jsdom'); 2 | window = jsdom.jsdom().defaultView; 3 | document = window.document; 4 | 5 | global.location = { href: '' }; 6 | 7 | $ = jQuery = require('jquery'); 8 | 9 | $('body').html('
'); 10 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --reporter spec 2 | --require should 3 | --require ./test/mocha 4 | --require ./lib/evil-blocks 5 | --compilers coffee:coffee-script/register 6 | -------------------------------------------------------------------------------- /test/slim_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../lib/evil-blocks-rails' 2 | 3 | require 'slim' 4 | EvilBlocks.install_to_slim! 5 | 6 | describe 'Slim hack' do 7 | 8 | it 'adds @dataRole alias' do 9 | expect(Slim::Template.new { '.name@nameField' }.render).to eq( 10 | '
') 11 | end 12 | 13 | it 'supports multiple roles' do 14 | expect(Slim::Template.new { '@a@b' }.render).to eq( 15 | '
') 16 | end 17 | 18 | it 'adds @@dataBlock alias' do 19 | expect(Slim::Template.new { '.name@@control' }.render).to eq( 20 | '
') 21 | end 22 | 23 | it 'supports multiple bloks' do 24 | expect(Slim::Template.new { '@@a@@b' }.render).to eq( 25 | '
') 26 | end 27 | 28 | end 29 | --------------------------------------------------------------------------------