Copy to clipboard button with Stimulus 2.0 (Beta)

David Ojeda - Jan 28 '20 - - Dev Community

Stimulus is a JavaScript framework developed by a team at Basecamp, and it aims to augment your existing HTML so things work without too much "connecting" code.

Contrary to other frameworks, Stimulus doesn't take over your front-end, so you can add it without too much hassle to your already running app.

Its documentation is very clear and digestible. Included in its handbook is an example of building a clipboard functionality, which I recommend you go through if you are trying Stimulus for the first time.

Right now we are replicating that functionality and adding a couple more things using a development build specified in this Pull Request (PR)

The Values and Classes APIs #202

This pull request introduces two new APIs to Stimulus: Values and Classes. These APIs are designed to improve upon, and ultimately obviate, the current Data Map API. We plan to ship them together in the upcoming Stimulus 2.0 release.

Values

Most uses of the Data Map API in Basecamp fall under the following categories:

  • Storing small strings, such as URLs, dates, or color values
  • Keeping track of a numeric index into a collection
  • Bootstrapping a controller with a JSON object or array
  • Conditioning behavior on a per-controller basis

However, the Data Map API only works with string values. That means we must manually convert to and from other types as needed. The Values API handles this type conversion work automatically.

Value properties

The Values API adds support for a static values object on controllers. The keys of this object are Data Map keys, and the values declare their data type:

export default class extends Controller {
  static values = {
    url: String,
    refreshInterval: Number,
    loadOnConnect: Boolean
  }

  connect() {
    if (this.loadOnConnectValue) {
      this.load()
    }
  }

  async load() {
    const response = await fetch(this.urlValue)
    // ...
    setTimeout(() => this.load(), this.refreshIntervalValue)
  }
}
Enter fullscreen mode Exit fullscreen mode

Supported types and defaults

This pull request implements support for five built-in types:

Type Serialized attribute value Default value
Array JSON.stringify(array) []
Boolean boolean.toString() false
Number number.toString() 0
Object JSON.stringify(object) {}
String Itself ""

Each type has a default value. If a value is declared in a controller but its associated data attribute is missing, the getter property will return its type's default.

Controller properties

Stimulus automatically generates three properties for each entry in the object:

Type Kind Property name Effect
Boolean, Number, Object, String Getter this.[name]Value Reads data-[identifier]-[name]-value
Array Getter this.[name]Values Reads data-[identifier]-[name]-values
Boolean, Number, Object, String Setter this.[name]Value= Writes data-[identifier]-[name]-value
Array Setter this.[name]Values= Writes data-[identifier]-[name]-values
Boolean, Number, Object, String Existential this.has[Name]Value Tests for presence of data-[identifier]-[name]-value
Array Existential this.has[Name]Values Tests for presence of data-[identifier]-[name]-values

Note that array values are always pluralized, both as properties and as attributes.

Value changed callbacks

In addition to value properties, the Values API introduces value changed callbacks. A value changed callback is a specially named method called by Stimulus whenever a value's data attribute is modified.

To observe changes to a value, define a method named [name]ValueChanged(). For example, a slideshow controller with a numeric index property might define an indexValueChanged() method to display the specified slide:

export default class extends Controller {
  static values = { index: Number }

  indexValueChanged() {
    this.showSlide(this.indexValue)
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Stimulus invokes each value changed callback once when the controller is initialized, and again any time the value's data attribute changes.

Even if a value's data attribute is missing when the controller is initialized, Stimulus will still invoke its value changed callback. Use the existential property to determine whether the data attribute is present.


Classes

Another common use of the Data Map API is to store CSS class names.

For example, Basecamp's copy-to-clipboard controller applies a CSS class to its element after a successful copy. To avoid inlining a long BEM string in our controller, and to keep things loosely coupled, we declare the class in a data-clipboard-success-class attribute:

<div data-controller="clipboard"
     data-clipboard-success-class="copy-to-clipboard--success">
Enter fullscreen mode Exit fullscreen mode

and access it using this.data.get("successClass") in the controller:

this.element.classList.add(this.data.get("successClass"))
Enter fullscreen mode Exit fullscreen mode

The Classes API formalizes and refines this pattern.

Class properties

The Classes API adds a static classes array on controllers. As with targets, Stimulus automatically adds properties for each class listed in the array:

// clipboard_controller.js
export default class extends Controller {
  static classes = [ "success", "supported" ]

  initialize() {
    if (/* ... */) {
      this.element.classList.add(this.supportedClass)
    }
  }

  copy() {
    // ...
    this.element.classList.add(this.successClass)
  }
}
Enter fullscreen mode Exit fullscreen mode
Kind Property name Effect
Getter this.[name]Class Reads the data-[identifier]-[name]-class attribute
Existential this.has[Name]Class Tests whether the data-[identifier]-[name]-class attribute is present

Declarations are assumed to be present

When you access a class property in a controller, such as this.supportedClass, you assert that the corresponding data attribute is present on the controller element. If the declaration is missing, Stimulus throws a descriptive error:

Screenshot showing error message: "Missing attribute 'data-clipboard-supported-class'"

If a class is optional, you must first use the existential property (e.g. this.hasSupportedClass) to determine whether its declaration is present.



Unifying target attributes

We've made a change to the target attribute syntax to align them with values and classes, and also to make the controller identifier more prominent by moving it into the attribute name.

The original syntax is:

<div data-target="[identifier].[name]">
Enter fullscreen mode Exit fullscreen mode

and the updated syntax is:

<div data-[identifier]-target="[name]">
Enter fullscreen mode Exit fullscreen mode

The original syntax is supported but deprecated

Stimulus 2.0 will support both syntaxes, but using the original syntax will display a deprecation message in the developer console. We intend to remove the original syntax in Stimulus 3.0.


Try it out in your application

Update the Stimulus entry in package.json to point to the latest development build:

"stimulus": "https://github.com/stimulusjs/dev-builds/archive/b8cc8c4/stimulus.tar.gz"

It includes new APIs that will be released with version 2.0 of the framework, so they are not yet available with the current stable production release.

What are we building?

A one-time password "copy to clipboard" button what wraps the DOM Clipboard API.

You can access the final working version on Glitch:

Starting off

First, we are creating our base HTML where the one-time password will be and the actual button to copy it:

<div>
  <label>
    One-time password:
    <input type="text" value="fbbb5593-1885-4164-afbe-aba1b87ea748" readonly="readonly">
  </label>

  <button>
    Copy to clipboard
  </button>
</div>
Enter fullscreen mode Exit fullscreen mode

Text input with "copy to clipboard button" rendered HTML

This doesn't do anything by itself; we need to add our Stimulus controller.

The controller definition

In Stimulus, a controller is a JavaScript object that automatically connects to DOM elements that have certain identifiers.

Let's define our clipboard controller. The main thing it needs to do? Grab the text on the input field and copy it to the clipboard:


(() => {
  const application = Stimulus.Application.start();

  application.register("clipboard", class extends Stimulus.Controller {
    // We'll get to this below
    static get targets() {
      return ['source']
    }

    copy() {
      // Here goes the copy logic 
    }
  });

})();
Enter fullscreen mode Exit fullscreen mode

Now, this is a valid controller that doesn't do anything because it's not connected to any DOM element yet.

Connecting the controller

Adding a data-controller attribute to our div will enable the connection:

<div data-controller="clipboard">

[...]
Enter fullscreen mode Exit fullscreen mode

Remember the static get targets() from above? That allows us to access DOM elements as properties in the controller.

Since there is already a source target, we can now access any DOM element with the attribute data-clipboard-target="source":

[...]

<input data-clipboard-target="source" type="text" value="fbbb5593-1885-4164-afbe-aba1b87ea748" readonly="readonly">

[...]
Enter fullscreen mode Exit fullscreen mode

Also, we need the button to actually do something. We can link the "Copy to clipboard" button to the copy action in our controller with another identifier: data-action="clipboard#copy". The HTML now looks like this:

<div data-controller="clipboard">
  <label>
    One-time password:
    <input data-clipboard-target="source" type="text" value="fbbb5593-1885-4164-afbe-aba1b87ea748" readonly="readonly">
  </label>

  <button data-action="clipboard#copy">
    Copy to clipboard
  </button>
</div>
Enter fullscreen mode Exit fullscreen mode

Our controller is now automatically connected to the DOM, and clicking the copy button will invoke the copy function; let's proceed to write it.

The copy function

This function is essentially a wrapper of the DOM Clipboard API. The logic goes like this:

[...]

copy() {
  this.sourceTarget.select();
  document.execCommand('copy');
}

[...]
Enter fullscreen mode Exit fullscreen mode

We take the source target we defined earlier, our text input that is, select its content, and use the Clipboard API to copy it to our clipboard.

At this point, the functionality is practically done! You can press the button and the one-time password is now available for you on your clipboard.

Moving further

The copy button works now, but we can go further. What if the browser doesn't support the Clipboard API or JavaScript is disabled?

If that's the case, we are going to hide the copy button entirely.

Checking API availability

We can check if the copy command is available to us by doing this:

document.queryCommandSupported("copy")
Enter fullscreen mode Exit fullscreen mode

One of the best places to check this is when the Stimulus controller connects to the DOM. Stimulus gives us some nice lifecycle callbacks so we can know when this happens.

We can create a connect function on our controller and it will be invoked whenever this controller connects to the DOM:

[...]

connect() {
  if (document.queryCommandSupported("copy")) 
    // Proceed normally
  }
} 

[...]
Enter fullscreen mode Exit fullscreen mode

One way to hide/show the copy button depending on the API availability is to initially load the page with the button hidden, and then displaying it if the API is available.

To achieve this we can rely on CSS:

.clipboard-button {
  display: none;
}

/* Match all elements with .clipboard-button class inside the element with .clipboard--supported class */
.clipboard--supported .clipboard-button {
  display: initial;
}
Enter fullscreen mode Exit fullscreen mode

Our button is now hidden from the beginning, and will only be visible when we add the .clipboard--supported class to our div.

To do it, we modify the connect lifecycle callback.

Here is where we can start to see major differences from this latest development version. With the actual production version you would need to specify the CSS class in the controller, effectively doing this:

[...]

connect() {
  if (document.queryCommandSupported("copy")) 
    this.element.classList.add('clipboard--supported');
  }
} 

[...]
Enter fullscreen mode Exit fullscreen mode

There is a new, better way to achieve it.

Classes API

Now, CSS classes can be actual properties of the controller. To do so, we need to add some identifiers to our HTML and add a new array to our controller:

<div data-controller="clipboard" data-clipboard-supported-class="clipboard--supported" class="clipboard">

[...]
Enter fullscreen mode Exit fullscreen mode
[...]

application.register("clipboard", class extends Stimulus.Controller {

[...]

  static classes = ['supported']

  connect() {
    if (document.queryCommandSupported("copy")) 
      this.element.classList.add(this.supportedClass);
    }
  } 
[...]
Enter fullscreen mode Exit fullscreen mode

Great! Now we can access our supported class string from our controller with this.supportedClass. This will help keep things loosely coupled.

The clipboard real-life example from Stimulus' handbook ends here. Now, to show the other newest additions and use the Classes API once more, we're adding the following functionality:

  • A new style to the "Copy to clipboard" button once it has been clicked
  • A refresh interval for the one-time password. This will generate a new password every 2.5 seconds
  • A data attribute to keep track of how many times the password has been generated

Values API

This, along with the Classes API, is one of the new additions to Stimulus. Before this API you would need to add arbitrary values to your controller with the Data Map API, that is, adding data-[identifier]-[variable-name] to your DOM element, and then parsing that value in your controller.

This created boilerplate such as getters and setters with calls to parseFloat(), parseInt(), JSON.stringify(), etc. This is how it will work with the Values API:

<div data-controller="clipboard" data-clipboard-supporte-class="clipboard--supported" data-clipboard-refresh-interval-value="2500" class="clipboard">

[...]
Enter fullscreen mode Exit fullscreen mode
[...]

application.register("clipboard", class extends Stimulus.Controller {

[...]

  static values = {
    refreshInterval: Number
  }

  connect() {
    if (document.queryCommandSupported("copy")) 
      this.element.classList.add(this.supportedClass);
    }
    // Access refreshInterval value directly
    this.refreshIntervalValue; // 2500
  } 
[...]
Enter fullscreen mode Exit fullscreen mode

Accessing your controller values is now cleaner since you don't need to write your getters and setters, nor do you need to parse from String to the type you need.

Moving forward, let's write the one-time password refresh.

Implementing password generation

We're going to define a new function to create a new random password. I grabbed this random UUID generator snippet from the internet:

([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
                (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16));
Enter fullscreen mode Exit fullscreen mode

Adding it to our Stimulus controller:

  connect() {
    if (document.queryCommandSupported("copy")) 
      this.element.classList.add(this.supportedClass);
    }
    if(this.hasRefreshIntervalValue) {
          setInterval(() => this.generateNewPassword(), this.refreshIntervalValue)  
    } 
  } 

  // copy function

  generateNewPassword() {
    this.sourceTarget.value = ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
                (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16));
  }
[...]
Enter fullscreen mode Exit fullscreen mode

We use setInterval to refresh our password text field each 2500ms since that's the value we defined in the DOM.

Our refresh feature is now working! Some things still missing:

  • Add new style when copy button is clicked
  • Keep track of how many times a password is generated

Giving all we have learned so far, this is what's need to be done:

  • Add a new CSS class to the stylesheet, DOM element, and controller
  • Add this new class when the button is clicked, and remove it when the password is refreshed
  • Add to a counter when the password refreshes

This is how it will look at the end:

/* CSS */

.clipboard-button {
 display: none;
}

.clipboard--supported .clipboard-button {
  display: initial;
}

.clipboard--success .clipboard-button {
  background-color: palegreen;
}
Enter fullscreen mode Exit fullscreen mode
<!-- HTML -->

<div data-controller="clipboard" 
     data-clipboard-refresh-interval-value="2500"
     data-clipboard-supported-class="clipboard--supported" 
     data-clipboard-success-class="clipboard--success"      
     data-clipboard-times-generated-value="1" 
     >

      <label>
        One-time password: <input data-clipboard-target="source" type="text" value="fbbb5593-1885-4164-afbe-aba1b87ea748" readonly="readonly">
      </label>

      <button data-action="clipboard#copy"               
              class="clipboard-button" >
        Copy to Clipboard
      </button>

    </div>
Enter fullscreen mode Exit fullscreen mode
 // JavaScript

 (() => {
    const application = Stimulus.Application.start()

    application.register("clipboard", class extends Stimulus.Controller {

      static get targets() {
        return ['source']
      }

      static values = {              
        refreshInterval: Number,
        timesGenerated: Number
      }

      static classes = ['supported', 'success'];

      connect() {                 
        if (document.queryCommandSupported("copy")) {
          this.element.classList.add(this.supportedClass);                
        }                            
        if(this.hasRefreshIntervalValue) {
          setInterval(() => this.generateNewPassword(), this.refreshIntervalValue)  
        } 
      }


      copy() {              
        this.sourceTarget.select();
        document.execCommand('copy');
        this.element.classList.add(this.successClass);
      }

      generateNewPassword() {              
        this.sourceTarget.value = ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
          (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16));     
        this.element.classList.remove(this.successClass);
        this.timesGeneratedValue++;
      }                  

      // NEW! Read about it below
      timesGeneratedValueChanged() {              
        if(this.timesGeneratedValue !== 0 && this.timesGeneratedValue % 3 === 0) {
          console.info('You still there?');
        }
      }

    });

 })();
Enter fullscreen mode Exit fullscreen mode

Apart from what we've already discussed about the Values API, there is also something new: Value changed callbacks.

These callbacks are called whenever a value changes, and also once when the controller is initialized. They are connected automatically given we follow the naming convention of [valueName]ValueChanged().

We use it to log a message each time the password has been refreshed three times, but they can help with state management in a more complex use case.

Wrapping up

I've created multiple Stimulus controllers for my daily job, and I must say that I always end up pleased with the results. Stimulus encourages you to keep related code together and, combined with the additional HTML markup required, ends up making your code much more readable.

If you haven't tried it yet, I highly recommend going for it! It offers a different perspective, one of magic 🧙🏻‍♂️.

Thanks for reading me 👋🏼.

. . . . . . . . . . . . . . . . . . . . . . . . . . .