Skip to content

Commit

Permalink
Document a propshaft approach to JS/CSS
Browse files Browse the repository at this point in the history
Resolves #1064

Given Rails 8 has been released without transpilation or bundled
JavaScript by default, this documentation update demonstrates how to
use Stimulus and CSS in a ViewComponent without webpacker.

A more terse demonstration would be possible without the view component
arguments and/or less complicated Stimulus behaviour however that might
be less instructive.

One of the trickier aspects of binding Stimulus controllers is often
determining the correct data-controller key to use, especially when
namespaces and/or multi-word component names are involved which this example
makes explicit.

Approach 2 demonstrates the Stimulus behaviour but not the css, that
would likely involve dartsass-rails
  • Loading branch information
jkotchoff committed Nov 8, 2024
1 parent fb2e917 commit 2aec553
Showing 1 changed file with 196 additions and 51 deletions.
247 changes: 196 additions & 51 deletions docs/guide/javascript_and_css.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,202 @@ parent: How-to guide

While ViewComponent doesn't provide any built-in tooling to do so, it’s possible to include JavaScript and CSS alongside components.


## Propshaft / Stimulus

To use a [transpiler-less and bundler-less approach to JavaScript](https://world.hey.com/dhh/modern-web-apps-without-javascript-bundling-or-transpiling-a20f2755) (the default for Rails 8), Stimulus and CSS can be used inside ViewComponents one of two ways;

Check warning on line 14 in docs/guide/javascript_and_css.md

View workflow job for this annotation

GitHub Actions / prose

[vale] reported by reviewdog 🐶 [Microsoft.Semicolon] Try to simplify this sentence. Raw Output: {"message": "[Microsoft.Semicolon] Try to simplify this sentence.", "location": {"path": "docs/guide/javascript_and_css.md", "range": {"start": {"line": 14, "column": 256}}}, "severity": "INFO"}

### Upgrading a pre-Rails 8 app

```ruby
# Gemfile (then run `bundle install`)
gem 'importmap-rails' # JavaScript version/digests without transpiling/bundling
gem 'propshaft' # Load static assets like JavaScript/CSS/images without transpilation/webpacker
gem 'stimulus-rails' # Hotwire JavaScript approach
```

```js
// app/javascript/controllers/application.js
import { Application } from "@hotwired/stimulus"

const application = Application.start()

application.debug = false
window.Stimulus = application

export { application }
```

```js
// app/javascript/controllers/index.js
import { application } from "controllers/application"
```

```ruby
# config/importmap.rb
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "application", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
```

### Approach 1 - Default _app/components_ ViewComponent directory using named Stimulus controllers, no autoloading

Locate CSS and Stimulus js with a ViewComponent. This example demonstrates a _HelloWorldComponent_ in an _examples_ namespace with a sidecar file naming approach:

```
app/components
├── ...
├── examples
| ├── hello_world_component
| | ├── hello_world_component_controller.js
| | ├── hello_world_component.css
| | └── hello_world_component.html.erb
| └── hello_world_component.rb
├── ...
```

##### 1. Prepare _app/components_ as an asset path for css and ensure hot reloads of Stimulus JavaScript
```ruby
# config/application.rb
...
config.assets.paths << "app/components"
config.importmap.cache_sweepers << config.root.join("app/components")
...
```

##### 2. Pin ViewComponent Stimulus import map entries
```ruby
# config/importmap.rb
...
pin_all_from "app/components"
```

##### 3. Expose the Stimulus controller with a named key:

```ruby
# app/javascript/controllers/index.js
...
import HelloWorldComponentController from "examples/hello_world_component/hello_world_component_controller";
application.register("examples--hello-world-component", HelloWorldComponentController);
```

##### 4. Implement the ViewComponent with custom CSS and Stimulus behaviour:

```ruby
# app/components/examples/hello_world_component.rb
class Examples::HelloWorldComponent < ViewComponent::Base
def initialize(title:)
@title = title
super
end
end
```

```erb
<!-- app/components/examples/hello_world_component/hello_world_component.html.erb -->
<%= stylesheet_link_tag "examples/hello_world_component/hello_world_component" %>
<h1><%= @title %></h1>
<p><%= content %></p>
<div data-controller="examples--hello-world-component">
<p class="hello-world" id="<%= @id %>" data-examples--hello-world-component-target="output">
This div will be updated by the controller
</p>
<button data-action="click->examples--hello-world-component#greet">Toggle Greeting</button>
</div>
```

```css
/* app/components/examples/hello_world_component/hello_world_component.css */
.hello-world {
color: blue;
}
```

```js
// app/components/examples/hello_world_component/hello_world_component_controller.js
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
static targets = ["output"]

initialize() {
console.log("Component initialized!");
}

connect() {
console.log("Component connected!");

this.outputTarget.textContent = "This div has been initialised by stimulus and will be updated when you click the button"
}

greet() {
const currentText = this.outputTarget.textContent;
this.outputTarget.textContent = currentText === "Hello from Stimulus!"
? "Goodbye from Stimulus!"
: "Hello from Stimulus!";
}
}
```

##### 5. Render the component in a rails view (or a [ViewComponent preview](previews.md)) to see the end result:

```erb
<!-- app/views/layouts/application.html.erb -->
<body>
...
<%= render(Examples::HelloWorldComponent.new(title: "Hello World!")) {
"<em>This</em> will demonstrate the use of <b>Stimulus</b> and <b>CSS</b> in a ViewComponent".html_safe
}
%>
...
```

### Approach 2 - Autoloaded ViewComponents in a sub-directory

Stimulus controllers [will not currently autoload](https://github.com/ViewComponent/view_component/issues/1064#issuecomment-1163314487) if ViewComponents are located at:

Check failure on line 167 in docs/guide/javascript_and_css.md

View workflow job for this annotation

GitHub Actions / prose

[vale] reported by reviewdog 🐶 [Microsoft.Contractions] Use 'won't' instead of 'will not'. Raw Output: {"message": "[Microsoft.Contractions] Use 'won't' instead of 'will not'.", "location": {"path": "docs/guide/javascript_and_css.md", "range": {"start": {"line": 167, "column": 23}}}, "severity": "ERROR"}

```
app/components
```

a workaround is to put ViewComponents in a subdirectory:

```
app/frontend/components
```

and then autoload them in import map:

```ruby
# config/importmap.rb
...
pin_all_from "app/frontend/components", under: "controllers", to: "components"
```

which also requires adjustment of the ViewComponent defaults to account for the sub-directory path:

```ruby
# config/application.rb
config.autoload_paths << Rails.root.join("app/frontend/components")
config.importmap.cache_sweepers << Rails.root.join("app/frontend")
config.assets.paths << Rails.root.join("app/frontend")
config.view_component.view_component_path = "app/frontend/components"
```

allowing the autoloaded Stimulus controllers in views eg.
```erb
<!-- app/components/examples/hello_world_component/hello_world_component.html.erb -->
...
<div data-controller="examples--hello-world-component--hello-world-component">
...
```

## Webpacker

To use the Webpacker gem to compile assets located in `app/components`:

1. In `config/webpacker.yml`, add `"app/components"` to the `additional_paths` array (for example `additional_paths: ["app/components"]`).
Expand Down Expand Up @@ -115,54 +311,3 @@ class Comment extends HTMLElement {
}
customElements.define('my-comment', Comment)
```

## Stimulus

In Stimulus, create a 1:1 mapping between a Stimulus controller and a component. To load in Stimulus controllers from the `app/components` tree, amend the Stimulus boot code in `app/javascript/controllers/index.js`:

```js
import { Application } from "stimulus"
import { definitionsFromContext } from "stimulus/webpack-helpers"

const application = Application.start()
const context = require.context("controllers", true, /\.js$/)
const contextComponents = require.context("../../components", true, /_controller\.js$/)
application.load(
definitionsFromContext(context).concat(
definitionsFromContext(contextComponents)
)
)
```

This enables the creation of files such as `app/components/widget_controller.js`, where the controller identifier matches the `data-controller` attribute in the component's HTML template.

After configuring Webpack to load Stimulus controller files from the `components` directory, add the path to `additional_paths` in `config/webpacker.yml`:

```yml
additional_paths: ["app/components"]
```
When placing a Stimulus controller inside a sidecar directory, be aware that when referencing the controller [each forward slash in a namespaced controller file’s path becomes two dashes in its identifier](
https://stimulusjs.org/handbook/installing#controller-filenames-map-to-identifiers):
```console
app/components
├── ...
├── example
| ├── component.rb
| ├── component.css
| ├── component.html.erb
| └── component_controller.js
├── ...
```

`component_controller.js`'s Stimulus identifier becomes: `example--component`:

```erb
<div data-controller="example--component">
<input type="text">
<button data-action="click->example--component#greet">Greet</button>
</div>
```

See [Generators Options](generators.html#generate-a-stimulus-controller) to generate a Stimulus controller alongside the component using the generator.

0 comments on commit 2aec553

Please sign in to comment.