Search code examples
ruby-on-railsruby-on-rails-7stimulusjs

Triggering Flash message from stimulus controller?


I’m trying to implement share functionality with rails7, stimulus and navigator.share, which is not supported in every browser. For those cases, I need to copy a link to a clipboard but show a flash message. I have a problem with the flash message. What is the correct approach to solving this problem, and is it even possible to do so?

Share button

<div data-controller="share" 
     data-share-target="trigger" 
     data-share-url-value="<%= post_url(@post) %>"  
     data-share-name-value="<%= @post.name %>">
  <%= button_to "", data: { action: "click->share#share" } do %>
    <%= inline_svg_tag "svg/share.svg", class: 'h-6 w-6' %>
  <% end %>
</div>

Stimulus controller

import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["trigger"];
  static values = ["name"];

  connect() {}

  async share(e) {
    e.preventDefault();

    const shareData = {
      name: this.nameValue,
      url: this.data.get("urlValue"),
      text: "some cool text",
      title: "title here",
    };

    try {
      if (navigator.share) {
        await navigator.share(shareData);
      } else {
        navigator.clipboard.writeText(this.data.get("urlValue"));

        // Trigger Flash message here
      }
    } catch (err) {
      console.log(err);
    }
  }
}

My flash

app/views/layouts/application.html.erb

<body class="min-h-full flex flex-col h-full">
    <main class="flex-1">
      <div id="flash" class="w-full">
        <% flash.each do |key, value| %>
          <div>
            <p><%= value %></p>
          </div>
          <% end %>
      </div>
    
      <%= yield %>
    </main>
  </body>

Is this even possible? If not, how would you implement this? I would also appreciate any feedback regarding the implementation.


Solution

  • There are a few approaches to solving this problem, the simplest would be to add the ability to dynamically inject the message in the same Share controller.

    Here is a full code example below.

    1. HTML

    • This builds on what you had in your base template and also the HTML specific for the controller with a few changes.
    • Firstly, we add a template element to the inside the #flash element to house the client-side HTML content to copy for each message.
    • We give this template a data attribute (can be anything) along with the actual content inside that houses the message text.
    • For the controlled element, there is no need for your data-share-target="trigger" as you can always access the controlled element with this.element but it looks like you were not using it anyway.
    • For your button, remember that adding type="button" avoids the need to use event.preventDefault and it works better for accessibility.
    • Remember to always include an accessible label for your buttons if you have a button that is an icon only. I have added aria-label but you could also add a span with sr-only (looks like you are using Tailwind).
    <body>
      <div id="flash" class="w-full" aria-live="polite">
        <!-- ...flash.each do etc  -->
        <template data-template>
          <div><p data-value></p></div>
        </template>
      </div>
      <section>
        <h4>Share stuff</h4>
        <div
          data-controller="share"
          data-share-name-value="some-name"
          data-share-url-value="path/to/post/"
        >
          <button type="button" data-action="share#share" aria-label="share">
            Share
          </button>
        </div>
      </section>
    </body>
    

    2. Controller (JS)

    • It looks like the way you were declaring your static values was incorrect, this should be an object syntax so that you can declare the type of the value for each one.
    • Building on your controller code, we can add a new method addMessage that will find the element with the id flash and then template element inside. It will clone that element and then find where to put the message content. Then it will append this node into the flash container.
    • Optional: Include a timeout on the message and remove it when you know the user has had time to see it.
    • Once you have this addMessage method, you can use this method to show the 'clipboard updated' message and any errors if you need them to be shown to the user.
    • Finally, the async/await feels like it is new and should be used but I think in this case it makes the code less readable. Using a simple then callback on the promise is more than sufficient for your use case.
    import { Controller } from "@hotwired/stimulus";
    
    export default class extends Controller {
      static values = {
        messageTimeout: { default: 2.5 * 1000, type: Number },
        name: String,
        url: String,
      };
    
      addMessage(content, { type = "error" } = {}) {
        const flashContainer = document.getElementById("flash");
    
        if (!flashContainer) return;
    
        const template = flashContainer.querySelector("[data-template]");
        const node = template.content.firstElementChild.cloneNode(true);
        node.querySelector("[data-value]").innerText = content.message;
    
        flashContainer.append(node);
    
        // optional - add timeout to remove after 2.5 seconds
        window.setTimeout(() => {
          node.remove();
        }, this.messageTimeoutValue);
      }
    
      share() {
        const name = this.nameValue;
        const url = this.urlValue;
    
        const shareData = {
          name,
          text: "some cool text",
          title: "title here",
          url,
        };
    
        try {
          if (navigator.share) {
            navigator.share(shareData).catch((error) => this.handleError(error));
          } else {
            navigator.clipboard.writeText(url);
            this.addMessage({ message: "copied to clipboard" }, { type: 'info' });
          }
        } catch (error) {
          this.handleError(error);
        }
      }
    }
    

    Alternative approach

    Another way to do this would be to split out the flash message behaviour to a new controller. This would be useful if you expect other code to need to create messages dynamically.

    Here is a reference implementation you could use for this.

    https://github.com/wagtail/wagtail/blob/main/client/src/controllers/MessagesController.ts (Wagtail, a CMS built on Django, has a similar global messages use case).

    You could also use a Stimulus approach for the 'remove after a delay' part only, here is a reference implementation for the 'toast' style removal.

    https://github.com/stimulus-components/stimulus-notification/blob/master/src/index.ts

    Best to start simple and only add this separate controller behaviour only if you need it though.