Skip to content

Commit

Permalink
Export the morph function (#58)
Browse files Browse the repository at this point in the history
  • Loading branch information
hopsoft authored Feb 29, 2024
1 parent 3bf7a3b commit 299573b
Show file tree
Hide file tree
Showing 37 changed files with 509 additions and 346 deletions.
4 changes: 4 additions & 0 deletions .config/tocer/configuration.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
label: "## Table of Contents"
patterns:
- "README.md"
root_dir: "."
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ RUN apt-get -y update && \
apt-get -y --no-install-recommends install \
build-essential \
curl \
git \
htop \
libjemalloc2 \
pkg-config \
sqlite3 \
Expand Down
67 changes: 55 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
</h1>
<p align="center">
<a href="http://blog.codinghorror.com/the-best-code-is-no-code-at-all/">
<img alt="Lines of Code" src="https://img.shields.io/badge/loc-261-47d299.svg" />
<img alt="Lines of Code" src="https://img.shields.io/badge/loc-295-47d299.svg" />
</a>
<a href="https://codeclimate.com/github/hopsoft/turbo_boost-streams/maintainability">
<img src="https://api.codeclimate.com/v1/badges/a6671f4294ec0f21f732/maintainability" />
Expand Down Expand Up @@ -79,6 +79,8 @@ You can `invoke` any DOM method on the client with Turbo Streams.
- [Usage](#usage)
- [Method Chaining](#method-chaining)
- [Event Dispatch](#event-dispatch)
- [Morph](#morph)
- [Morph Method](#morph-method)
- [Syntax Styles](#syntax-styles)
- [Extending Behavior](#extending-behavior)
- [Implementation Details](#implementation-details)
Expand Down Expand Up @@ -193,6 +195,56 @@ turbo_stream
.invoke(:dispatch_event, args: ["turbo-ready:demo", {bubbles: true, detail: {...}}]) # set event options
```

### Morph

You can morph elements with the `morph` method.

```ruby
turbo_stream.invoke(:morph, args: [render("path/to/partial")], selector: "#my-element")
```

> [!NOTE]
> TurboBoost Streams uses [Idiomorph](https://github.com/bigskysoftware/idiomorph) for morphing.
The following options are used to morph elements.

```js
{
morphStyle: 'outerHTML',
ignoreActiveValue: true,
head: { style: 'merge' },
callbacks: { beforeNodeMorphed: (oldNode, _) => ... }
}
```

> [!TIP]
> The callbacks honor the `data-turbo-permanent` attribute and is aware of the [Trix](https://trix-editor.org/) editor.
### Morph Method

The morph method is also exported to the `TurboBoost.Streams` global and is available for client side morphing.

```js
TurboBoost.Streams.morph.method // → function(targetNode, htmlString, options = {})
```

You can also override the `morph` method if desired.

```js
TurboBoost.Streams.morph.method = (targetNode, htmlString, options = {}) => {
// your custom implementation
}
```

It also support adding a delay before morphing is performed.

```js
TurboBoost.Streams.morph.delay = 50 // → 50ms
```

> [!TIP]
> Complex test suites may require a delay to ensure the DOM is ready before morphing.
### Syntax Styles

You can use [`snake_case`](https://en.wikipedia.org/wiki/Snake_case) when invoking DOM functionality.
Expand All @@ -217,21 +269,12 @@ If you add new capabilities to the browser, you can control them from the server
// JavaScript on the client
import morphdom from 'morphdom'

window.MyNamespace = {
morph: (from, to, options = {}) => {
morphdom(document.querySelector(from), to, options)
}
}
window.MyNamespace = { coolStuff: (arg) => { ... } }
```

```ruby
# Ruby on the server
turbo_stream.invoke "MyNamespace.morph",
args: [
"#demo",
"<div id='demo'><p>You've changed...</p></div>",
{children_only: true}
]
turbo_stream.invoke "MyNamespace.coolStuff", args: ["Hello World!"]
```

### Implementation Details
Expand Down
15 changes: 9 additions & 6 deletions Rakefile
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
# frozen_string_literal: true

require "bundler/setup"
require "bundler/gem_tasks"
require "rake/testtask"

APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)

load "rails/tasks/engine.rake"
load "rails/tasks/statistics.rake"
require "bundler/gem_tasks"

Rake::TestTask.new do |test|
test.libs << "test"
test.test_files = FileList["test/**/*_test.rb"]
test.warning = false
desc "Run tests"
task :test, [:file] do |_, args|
command = (ARGV.length > 1) ?
"bin/rails test #{ARGV[1..].join(" ")}" :
"bin/rails test:all"
puts command
exec command
end

task default: :test
2 changes: 1 addition & 1 deletion app/assets/builds/@turbo-boost/streams.js

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions app/assets/builds/@turbo-boost/streams.js.map

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion app/javascript/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import VERSION from './version'
import schema from './schema'
import morph from './morph'
import { invoke, invokeEvents } from './invoke'

if (!self['Turbo'])
Expand All @@ -14,7 +15,7 @@ if (!Turbo['StreamActions'])

Turbo.StreamActions.invoke = invoke
self.TurboBoost = self.TurboBoost || {}
self.TurboBoost.Streams = { invoke, invokeEvents, schema, VERSION }
self.TurboBoost.Streams = { invoke, invokeEvents, morph, schema, VERSION }

console.info('@turbo-boost/streams has initialized and registered new stream actions with Turbo.')

Expand Down
20 changes: 11 additions & 9 deletions app/javascript/invoke.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import morph from './morph'

export const invokeEvents = {
const invokeEvents = {
before: 'turbo-boost:stream:before-invoke',
after: 'turbo-boost:stream:after-invoke',
finish: 'turbo-boost:stream:finish-invoke'
Expand Down Expand Up @@ -30,16 +30,16 @@ function withInvokeEvents(receiver, detail, callback) {
if (result instanceof Promise) promise = result

if (promise)
promise.then(
() => {
promise
.then(() => {
options.detail.promise = 'fulfilled'
target.dispatchEvent(new CustomEvent(invokeEvents.finish, options))
},
() => {
})
.catch(error => {
options.detail.promise = 'rejected'
options.detail.error = error
target.dispatchEvent(new CustomEvent(invokeEvents.finish, options))
}
)
})
else target.dispatchEvent(new CustomEvent(invokeEvents.finish, options))
}

Expand All @@ -61,7 +61,7 @@ function invokeDispatchEvent(method, args, receivers) {
function invokeMorph(method, args, receivers) {
const html = args[0]
const detail = { method, html }
receivers.forEach(receiver => withInvokeEvents(receiver, detail, object => morph(object, html)))
receivers.forEach(receiver => withInvokeEvents(receiver, detail, object => morph.method(object, html)))
}

function invokeAssignment(method, args, receivers) {
Expand Down Expand Up @@ -93,7 +93,7 @@ function performInvoke(method, args, receivers) {
return invokeMethod(method, args, receivers)
}

export function invoke() {
function invoke() {
const payload = JSON.parse(this.templateContent.textContent)
const { id, selector, receiver, method, args, delay } = payload
let receivers = [{ object: self, target: self }]
Expand All @@ -118,3 +118,5 @@ export function invoke() {
if (delay > 0) setTimeout(() => performInvoke(method, args, receivers), delay)
else performInvoke(method, args, receivers)
}

export { invoke, invokeEvents }
71 changes: 52 additions & 19 deletions app/javascript/morph.js
Original file line number Diff line number Diff line change
@@ -1,36 +1,69 @@
import { Idiomorph } from 'idiomorph'
import schema from './schema'

const input = /INPUT/i
const inputTypes = /date|datetime-local|email|month|number|password|range|search|tel|text|time|url|week/i
const textarea = /TEXTAREA/i
let _method
let _delay = 0

const trixEditor = /TRIX-EDITOR/i

const morphAllowed = node => {
if (node.nodeType !== Node.ELEMENT_NODE) return true
if (node !== document.activeElement) return true
function isElement(node) {
return node.nodeType === Node.ELEMENT_NODE
}

// don't morph elements marked as turbo permanent
if (
function isTurboPermanent(node) {
if (!isElement(node)) return false
return (
node.hasAttribute(schema.turboPermanentAttribute) &&
node.getAttribute(schema.turboPermanentAttribute) !== 'false'
)
return false
}

// don't morph active textarea
if (node.tagName.match(textarea)) return false
function isActive(node) {
if (!isElement(node)) return false
return node === document.activeElement
}

// don't morph active trix-editor
if (node.tagName.match(trixEditor)) return false
function morphAllowed(node) {
if (isTurboPermanent(node)) return false
if (isActive(node) && node.tagName.match(trixEditor)) return false
return true
}

// don't morph active inputs
return node.tagName.match(input) && node.getAttribute('type').match(inputTypes)
const defaultOptions = {
callbacks: { beforeNodeMorphed: (oldNode, _newNode) => morphAllowed(oldNode) },
morphStyle: 'outerHTML',
ignoreActiveValue: true,
head: { style: 'merge' }
}

const callbacks = {
beforeNodeMorphed: (oldNode, _newNode) => morphAllowed(oldNode)
function morph(element, html, options = {}) {
const callbacks = { ...defaultOptions.callbacks, ...options.callbacks }
options = { ...defaultOptions, ...options, callbacks }

return new Promise(resolve => {
setTimeout(() => {
Idiomorph.morph(element, html, options)
resolve()
}, _delay)
})
}

const morph = (element, html) => Idiomorph.morph(element, html, { callbacks })
_method = morph

export default {
get delay() {
return _delay
},

set delay(ms) {
_delay = ms
},

export default morph
get method() {
return _method
},

set method(fn) {
_method = fn
}
}
14 changes: 14 additions & 0 deletions bin/rails
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/usr/bin/env ruby
# This command will automatically be run when you run "rails" with Rails gems
# installed from the root of your application.

ENGINE_ROOT = File.expand_path("..", __dir__)
ENGINE_PATH = File.expand_path("../lib/xengine/engine", __dir__)
APP_PATH = File.expand_path("../test/dummy/config/application", __dir__)

# Set up gems listed in the Gemfile.
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"])

require "rails/all"
require "rails/engine/commands"
4 changes: 1 addition & 3 deletions bin/test
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
#!/usr/bin/env ruby
$: << File.expand_path("../test", __dir__)

require "bundler/setup"
require "rails/plugin/test"
exec "rake test #{ARGV.join " "}".strip
Loading

0 comments on commit 299573b

Please sign in to comment.