Rails: What's new slide out drawer

14 Jan 2024

Source code can be found here: https://github.com/jespr/rails-whats-new-slide-out

Create a new announcement model, for this example we’ll just use the Rails scaffold functionality since this isn’t really the focus of the tutorial: rails g scaffold announcement title body announcement_type - this will give us the controller with CRUD functionality and the model.

I’m using action_text for the announcement body, but you can leave that out. In order for this to work we need to install action_text in our Rails app if we don’t have it already:

bin/rails action_text:install

Run the migrations rails db:migrate

Let’s go ahead and create a new announcement by visiting http://localhost:3000/announcemens/new and fill in a test title and description (we can set the announcement type to new for now)

Let’s make a quick change to what an announcement looks like. Open app/views/announcement/_announcement.html.erb

And change it to this:

<div id="<%= dom_id announcement %>">
  <p class="my-5">
    <h2 class="font-bold text-2xl text-gray-900"><%= announcement.title %></h2>
  </p>

  <p class="my-5">
    <%= announcement.body %>
  </p>
</div>

We want a little bit more spacing between the announcements when you have multiple, so open app/views/announcements/index.html.erb and add space-y-12 to the div the announcements are rendered within so it looks like this:

<div id="announcements" class="min-w-full space-y-12">
	<%= render @announcements %>
</div>

Time for the fun part

Let’s make so when you click on a “What’s new” link inside of your product we slide out a drawer on the right side with a list of the announcements.

Let’s go ahead and add a link somewhere. In my case I have a dashboard view, so I’m going to add it to that:

<%= link_to "What's new?", announcements_path, data: { turbo_stream: true } %>

When we click this link it’ll make a TURBO_STREAM request to the announcement_controller’s index action.

In order for that to respond correctly, we want to create the following file app/views/announcements/index.turbo_stream.erb

And that file will have the following:

<%= turbo_stream.replace :announcements, partial: "announcements/announcements", locals: { announcements: @announcements } %>

What that is doing is that it replaces the content of the turbo_frame named :announcements with the content of the announcements/announcements partial. So let’s go ahead and create that partial in app/views/announcements/_announcements.html.erb

With the following:

<%= turbo_frame_tag :announcements do %>
  <%= render "announcements_list", announcements: @announcements %>
<% end %>

Let’s make a small refactor in our app/views/announcements/index.html.erb file. Let’s move this:

<div id="announcements" class="min-w-full space-y-12">
	<%= render announcements %>
</div>

Out into it’s own partial, so replace the above with:

<%= render announcements_list”, announcements: @announcements %>

And add it to this new file in app/views/announcements/_announcements_list.html.erb:

<div id="announcements" class="min-w-full space-y-12">
	<%= render announcements %>
 </div>

We now have the announcements list that we can use in both our index.html.erb and index.turbo_stream.erb files. Even though for the turbo_stream template it’s referenced from inside of the announcements partial so we can properly wrap it in a turbo_frame_tag.

The last little thing we need to do is add a turbo_frame_tag with the name of :announcements to our layout. We’ll just go ahead and add it right before the closing body tag in app/views/layouts/application.html.erb:

<body>
	<main class="container mx-auto mt-28 px-5 flex">
		<%= yield %>
	</main>

	<%= turbo_frame_tag :announcements %>
</body>

If you go refresh the page where you added the “What’s new” link you should see the list of announcements being rendered at the bottom of your page when you click it.

Let’s add the drawer

But we want it to by shown in a drawer on the right side? So let’s go ahead and add that functionality now. In our app/views/announcements/_announcements.html.erb file, wrap the announcements_list render line in a div, like this:

<%= turbo_frame_tag :announcements do %>
  <div class="fixed bg-white rounded max-w-lg p-4 right-3 top-3 bottom-3 shadow-md border overflow-y-scroll ">
    <%= render "announcements_list", announcements: @announcements %>
  </div>
<% end %>

We’ve added a div, with position fixed, given it a white background, made it rounded, given it a max width, some padding and inset it from the top, bottom and right side. We’ve also made it so if the content inside of it is larger than the viewport, then we can scroll it - and we’ve given it a shadow and a border to stand out from the content behind it - but you can of course do whatever you want :)

Make it so we can close it

We want to be able to close the drawer after you have clicked the link, without having to refresh your browser. This is where Stimulus.js comes in. Let’s create a new stimulus controller: rails g stimulus announcements - we want that to contain the following:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  close() {
    this.element.remove()
  }
}

Inside of app/views/announcements/_announcements.html.erb we add a button that we can click to close the drawer:

<%= turbo_frame_tag :announcements do %>
	<div class="fixed bg-white rounded max-w-lg p-4 right-3 top-3 bottom-3 shadow-md border overflow-y-scroll" data-controller="announcements">
		<button data-action="announcements#close">close</button>

		<%= render "announcements_list", announcements: @announcements %>
  </div>
<% end %>

As you can see we added a button that has a data-action attribute - the default event for a button is click so that’s why we write it as announcements#close instead of click->announcements#close - this makes it so when we click it, it invokes the close function in the announcements Stimulus controller.

If you open the drawer by clicking on the “What’s new” link, you’ll see the announcements being rendered on the right side of the page and if you click the “close” link, it’ll disappear. this.element is the element that data-controller is defined on, we can just call the DOM action remove() on that element.

Animations

We want it to slide in when you click the link to make it a little more fun. Let’s go ahead and modify our tailwindcss.config.js to add some custom animations. Add the following inside theme: { extend: {

theme: {
	extend: {
    keyframes: {
      "slide-in": {
        "0%": { transform: "translateX(100%)" },
        "100%": { transform: "translateX(0)" },
      },
      "slide-out": {
        "0%": { transform: "translateX(0)" },
        "100%": { transform: "translateX(100%)" },
      },
    },
    animation: {
      "slide-in": "slide-in 0.3s ease-out",
		"slide-out": "slide-out 0.3s ease-out",
    },
	},
},

Inside of app/views/announcements/_announcements.html.erb we can now add the animate-slide-in class to the div:

<%= turbo_frame_tag :announcements do %>
  <div class="animate-slide-in fixed bg-white rounded max-w-lg p-4 right-3 top-3 bottom-3 shadow-md border overflow-y-scroll" data-controller="announcements">
    <button data-action="announcements#close">close</button>

    <%= render "announcements_list", announcements: @announcements %>
  </div>
<% end %>

If you click on the “What’s new” link, you should see the drawer slide in (you might have to restart your server).

But if you click the close button, it just disappears. Let’s open up the stimulus controller app/javascript/controllers/announcements_controller.js and change the close function to this:

close() {
  this.element.classList.add("animate-slide-out")
  setTimeout(() => this.element.remove(), 300)
}

As you can see we add the animate-slide-out to the element and wait 300ms and then we remove the element. This is to ensure the animation has finished.

Close it when you click outside

We can easily add functionality where when you click outside of the drawer, we close it. Go ahead and add stimulus-use, bin/importmap pin stimulus-use and change your announcements_controller.js to the following:

import { Controller } from "@hotwired/stimulus"
import { useClickOutside } from "stimulus-use"

export default class extends Controller {
  connect() {
    useClickOutside(this)
  }

  close() {
    this.element.classList.add("animate-slide-out")
    setTimeout(() => this.element.remove(), 300)
  }

  clickOutside() {
    this.close()
  }
}

Notice how we have imported useClickOutside from stimulus-use and are calling that inside the connect() function which runs after the element is rendered on the page.

We then add the clickOutside function, which is triggered when you click outside of the drawer.

And that is basically how you add a slide out drawer with what’s new in your product :)

You can of course style things differently, add links to take you to the specific announcement item etc.

Here’s a link to the example app: https://github.com/jespr/rails-whats-new-slide-out