{"id":13416334,"url":"https://github.com/unabridged/motion","last_synced_at":"2025-04-07T17:06:12.750Z","repository":{"id":37075424,"uuid":"268623262","full_name":"unabridged/motion","owner":"unabridged","description":"Reactive frontend UI components for Rails in pure Ruby","archived":false,"fork":false,"pushed_at":"2023-03-17T06:10:32.000Z","size":1244,"stargazers_count":699,"open_issues_count":12,"forks_count":19,"subscribers_count":12,"default_branch":"main","last_synced_at":"2025-03-31T16:11:26.314Z","etag":null,"topics":["component","gem","no-javascript","presenter","rails","reactive","reactive-programming","ruby","ruby-gem","ruby-on-rails","view-components"],"latest_commit_sha":null,"homepage":"https://github.com/unabridged/motion","language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/unabridged.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE.txt","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2020-06-01T20:18:34.000Z","updated_at":"2025-03-13T06:24:53.000Z","dependencies_parsed_at":"2024-06-21T13:13:46.174Z","dependency_job_id":"fc8ce7e8-9cd8-44e3-a5d3-6b0f36c50f75","html_url":"https://github.com/unabridged/motion","commit_stats":null,"previous_names":[],"tags_count":16,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/unabridged%2Fmotion","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/unabridged%2Fmotion/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/unabridged%2Fmotion/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/unabridged%2Fmotion/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/unabridged","download_url":"https://codeload.github.com/unabridged/motion/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":247694873,"owners_count":20980733,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["component","gem","no-javascript","presenter","rails","reactive","reactive-programming","ruby","ruby-gem","ruby-on-rails","view-components"],"created_at":"2024-07-30T21:00:57.261Z","updated_at":"2025-04-07T17:06:12.720Z","avatar_url":"https://github.com/unabridged.png","language":"Ruby","readme":"# Motion\n\n[![Gem Version](https://badge.fury.io/rb/motion.svg)](https://badge.fury.io/rb/motion)\n[![npm version](https://badge.fury.io/js/%40unabridged%2Fmotion.svg)](https://badge.fury.io/js/%40unabridged%2Fmotion)\n[![Build Status](https://travis-ci.com/unabridged/motion.svg?branch=main)](https://travis-ci.com/unabridged/motion)\n[![Maintainability](https://api.codeclimate.com/v1/badges/3167364a38b1392a5478/maintainability)](https://codeclimate.com/github/unabridged/motion/maintainability)\n[![Test Coverage](https://api.codeclimate.com/v1/badges/3167364a38b1392a5478/test_coverage)](https://codeclimate.com/github/unabridged/motion/test_coverage)\n[![Ruby Code Style](https://img.shields.io/badge/Ruby_Code_Style-standard-brightgreen.svg)](https://github.com/testdouble/standard)\n[![JavaScript Code Style](https://img.shields.io/badge/JavaScript_Code_Style-standard-brightgreen.svg)](https://standardjs.com)\n\nMotion allows you to build reactive, real-time frontend UI components in your Rails application using pure Ruby. Check out some [live examples](https://motion-demos.herokuapp.com) and [the code for the examples](https://github.com/unabridged/motion-demos).\n\n* Plays nicely with the Rails monolith you have.\n* Peacefully coexists with your existing tech: Strong Parameters, Turbolinks, Trix, React, Vue, etc.\n* Real-time frontend UI updates from frontend user interaction AND server-side updates.\n* Leans on ActionCable and ViewComponent for the heavy lifting.\n* No more frontend models, stores, or syncing; your source of truth is the database you already have.\n* No JavaScript required!\n\n\n## Installation\n\n1. Install Motion gem and JS package\n\nMotion has Ruby and JavaScript parts, execute both of these commands:\n\n```sh\nbundle add motion\nyarn add @unabridged/motion\n```\n\n2. Install ViewComponent\n\nYou need a view component library to use Motion. Technically, any view component library that\nimplements the [`render_in` interface that landed in Rails 6.1](https://github.com/rails/rails/pull/36388)\nshould be compatible, but Motion is actively developed and tested against\nGithub's [ViewComponent](https://github.com/github/view_component).\n\nInstallation instructions for [ViewComponent are here](https://github.com/github/view_component#installation).\n\n3. Install ActionCable\n\nMotion communicates over and therefore requires [ActionCable](https://guides.rubyonrails.org/action_cable_overview.html).\n\n4. Run install script\n\nAfter installing all libraries, run the install script:\n\n```sh\nbin/rails g motion:install\n```\n\nThis will install 2 files, both of which you are free to leave alone.\n\n\n## How does it work?\n\nMotion allows you to mount special DOM elements (henceforth \"Motion components\") in your standard Rails views that can be real-time updated from frontend interactions, backend state changes, or a combination of both.\n\n- **Websockets Communication** - Communication with your Rails backend is performed via ActionCable\n- **No Full Page Reload** - The current page for a user is updated in place.\n- **Fast DOM Diffing** - DOM diffing is performed when replacing existing content with new content.\n- **Server Triggered Events** - Server-side events can trigger updates to arbitrarily many components via WebSocket channels.\n- **Partial Page Replacement** - Motion does not use full page replacement, but rather replaces only the component on the page with new HTML, DOM diffed for performance.\n- **Encapsulated, consistent stateful components** - Components have continuous internal state that persists and updates. This means each time a component changes, new rendered HTML is generated and can replace what was there before.\n- **Blazing Fast** - Communication does not have to go through the full Rails router and controller stack. No changes to your routing or controller are required to get the full functionality of Motion.\n\n\n### Frontend interactions\n\nFrontend interactions can update your Motion components using standard JavaScript events that you're already familiar with: `change`, `blur`, form submission, and more. You can invoke Motion actions manually using JavaScript if you need to.\n\nThe primary way to handle user interactions on the frontend is by using `map_motion`:\n\n```ruby\nclass MyComponent \u003c ViewComponent::Base\n  include Motion::Component\n\n  attr_reader :total\n\n  def initialize(total: 0)\n    @total = 0\n  end\n\n  map_motion :add\n\n  def add\n    @total += 1\n  end\nend\n```\n\nTo invoke this motion on the frontend, add `data-motion='add'` to your component's template:\n\n```erb\n\u003cdiv\u003e\n  \u003cspan\u003e\u003c%= total %\u003e\u003c/span\u003e\n  \u003c%= button_tag \"Increment\", data: { motion: \"add\" } %\u003e\n\u003c/div\u003e\n```\n\nThis component can be included on your page the same as always with ViewComponent:\n\n```erb\n\u003c%= render MyComponent.new(total: 5) %\u003e\n```\n\nEvery 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.\n\n\n### Backend interactions\n\nBackend changes can be streamed to your Motion components in 2 steps.\n\n1. Broadcast changes using ActionCable after an event you care about:\n\n```ruby\nclass Todo \u003c ApplicationModel\n  after_commit :broadcast_created, on: :create\n\n  def broadcast_created\n    ActionCable.server.broadcast(\"todos:created\", name)\n  end\nend\n```\n\n2. Configure your Motion component to listen to an ActionCable channel:\n\n```ruby\nclass TopTodosComponent \u003c ViewComponent::Base\n  include Motion::Component\n\n  stream_from \"todos:created\", :handle_created\n\n  def initialize(count: 5)\n    @count = count\n    @todos = Todo.order(created_at: :desc).limit(count).pluck(:name)\n  end\n\n  def handle_created(name)\n    @todos = [name, *@todos.first(@count - 1)]\n  end\nend\n```\n\nThis will cause any user that has a page open with `TopTodosComponent` mounted on it to re-render that component's portion of the page.\n\nAll invocations of `stream_from` connected methods will cause the component to re-render everywhere, and unchanged rendered HTML will not perform any changes.\n\n## Periodic Timers\n\nMotion can automatically invoke a method on your component at regular intervals:\n\n```ruby\nclass ClockComponent \u003c ViewComponent::Base\n  include Motion::Component\n\n  def initialize\n    @time = Time.now\n  end\n\n  every 1.second, :tick\n\n  def tick\n    @time = Time.now\n  end\nend\n```\n\n## Motion::Event and Motion::Element\n\nMethods that are mapped using `map_motion` 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.\n\n```ruby\n  map_motion :example\n\n  def example(event)\n    event.type # =\u003e \"change\"\n    event.name # alias for type\n\n    # Motion::Element instance, the element that received the event.\n    event.target\n\n    # Motion::Element instance, the element with the event handler and the `data-motion` attribute\n    event.element\n\n\n    # Element API examples\n    element.tag_name # =\u003e \"input\"\n    element.value # =\u003e \"5\"\n    element.attributes # { class: \"col-xs-12\", ... }\n\n    # DOM element with aria-label=\"...\"\n    element[:aria_label]\n\n    # DOM element with data-extra-info=\"...\"\n    element.data[:extra_info]\n\n    # ActionController::Parameters instance with all form params. Also\n    # available on Motion::Event objects for convenience.\n    element.form_data\n  end\n```\n\nSee the code for full API for [Event](https://github.com/unabridged/motion/blob/main/lib/motion/event.rb) and [Element](https://github.com/unabridged/motion/blob/main/lib/motion/element.rb).\n\n## Callbacks\n\nMotion has callbacks which will let you pass data from a child component back up to the parent. Callbacks are created by calling `bind` with the name of a method on the parent component which will act as a handler. It returns a new callback which can be passed to child components like any other state. To invoke the handler from the callback, use `call`. If the handler accepts an argument, it will receive anything that is passed to `call`.\n\n**parent_component.rb**\n```ruby\n  # this will be called from the child component\n  def do_something(message)\n    puts \"Colonel Sandurz says: \"\n    puts message\n  end\n```\n\n**parent_component.html.erb**\n```erb\n  \u003c%= render ChildComponent.new(on_click: bind(:do_something)) %\u003e\n```\n\n**child_component.rb**\n```ruby\n  attr_reader :on_click\n\n  map_motion :click\n\n  def initialize(on_click:)\n    @on_click = on_click\n  end\n\n  def click\n    on_click.call(\"Do something!\") # an argument can be passed into `call`\n  end\n```\n\n**child_component.html.erb**\n```erb\n  \u003c%= button_tag \"You gotta help me, I can't make decisions!\", data: { motion: \"click\" } %\u003e\n```\n\n## Limitations\n\n* Due to the way that Motion components are replaced on the page, component HTML templates are limited to a single top-level DOM element. If you have multiple DOM elements in your template at the top level, you must wrap them in a single element. This is a similar limitation that React enforced until `React.Fragment` appeared and is for a very similar reason.\n\n* Information about the request to the server is lost after the first re-render. This may cause things like `_url` helpers to fail to find the correct domain automatically. A workaround is to provide the domain to the helpers, and cache as a local variable any information from the `request` object that you need. \n\n## Roadmap\n\nBroadly speaking, these initiatives are on our roadmap:\n\n- Enhanced documentation and usage examples\n- Support more ViewComponent-like libraries\n- Support for AnyCable\n- Support for server-side state to reduce over-the-wire HTML size\n- Support communication via AJAX instead of (or in addition to) websockets\n\n## Contributing\n\nBug reports and pull requests are welcome on GitHub at https://github.com/unabridged/motion.\n\n\n## License\n\nThe gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).\n","funding_links":[],"categories":["Ruby"],"sub_categories":[],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Funabridged%2Fmotion","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Funabridged%2Fmotion","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Funabridged%2Fmotion/lists"}