Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save pascallaliberte/a6343fa582728e926e617bb661627d89 to your computer and use it in GitHub Desktop.
Save pascallaliberte/a6343fa582728e926e617bb661627d89 to your computer and use it in GitHub Desktop.
Article for Boring Rails blog: "Using Stimulus with Shopify Polaris instead of React"
layout title
post
Using Stimulus with Shopify Polaris instead of React

This a guest post from Pascal Laliberté, author of Modest JS Works, a short ebook for those who want to write modest JavaScript, and then focus on all the other stuff that matters in building an app. {: .guest-intro }


For your next project, you're building a Shopify app.

Polaris is Shopify's UI kit, and it's built in React. But you'd much rather use some good old server-side HTML, handle form submissions the Rails way, sprinkle some Stimulus on top, even leave Turbolinks turned on. Shouldn't be too hard.

Shopify Polaris is okay with server-side HTML

Shopify offers two options for their Polaris component system:

  1. React Components, and
  2. CSS Components.

We'll be looking at that second option. Shopify offers plain HTML code samples and the Polaris CSS can be included into your project separately.

Switching to a component's HTML code sample, copying the code

A quote from their documentation:

If React doesn’t make sense for your application, you can use a CSS-only version of our components. This includes all the styles you need for every component in the library, but you’ll be responsible for writing the correct markup and updating classes and DOM attributes in response to user events.

Is it a lot of work to reproduce the interactivity that React provides Polaris, but using Stimulus, the library that automatically sprinkles interactivity onto HTML? It turns out, you can go a long way with just Stimulus.

Most of Polaris' interactivity can be replicated easily with Stimulus

For some of the Polaris components, there isn't any interactivity. The link component is just a link, the checkbox component is mostly divs and classes, an id and some aria- attributes.

The text input field doesn't need much interactivity either.

# _polaris_text_field_currency.html.erb
<div style="--top-bar-background: #00848e; --top-bar-background-lighter: #1d9ba4; --top-bar-color: #f9fafb; --p-frame-offset: 0px;">
  <div class="">
    <div class="Polaris-Labelled__LabelWrapper">
      <div class="Polaris-Label">
      
        <%= f.label field_name, 
          "#{field_label}", 
          class: 'Polaris-Label__Text',
          id: "PolarisTextLabel_#{field_name}"
        %>
        
      </div>
    </div>
    <div class="Polaris-Connected">
      <div class="Polaris-Connected__Item Polaris-Connected__Item--primary">
        <div class="Polaris-TextField Polaris-TextField--hasValue">
          <div class="Polaris-TextField__Prefix" id="PolarisTextFieldPrefix_#{field_name}">$</div>
          
          <%= f.text_field field_name,
            class: "Polaris-TextField__Input Polaris-TextField__Input--alignRight",
            inputmode: 'decimal',
            'aria-labelledby': "PolarisTextLabel__#{field_name} PolarisTextFieldPrefix_#{field_name}",
            'aria-invalid': false,
            'aria-multiline': false
          %>
          
          <div class="Polaris-TextField__Backdrop"></div>
        </div>
      </div>
    </div>
  </div>
</div>

It's a lot of divs, but you get a nice hover treatment, for free. It's all CSS.

So for most components, you can add the little bit of interactivity you need using just Stimulus, and it's often just about adding and removing classes.

But let's take one that's a little more daunting: the Data Table component.

Example: Reproducing the Data Table's side-scrolling Using Stimulus

A simple Data Table is a great example of a lets-just-do-this-in-html component. Except, if you add too many columns, you might want to reproduce the side-scrolling nav widget that appears when the table needs to be scrollable. This is a great example for using Stimulus, and we'll go over the implementation. See the full demo up on Codepen.

A Data Table with the scroll navigation coded with Stimulus

Overall, we'll create a polaris-data-table controller right here.

# _sales_by_product.html.erb
<div>
  <div class="Polaris-DataTable"
    **data-controller="polaris-data-table"**
    data-polaris-data-table-nav-shown-class="Polaris-DataTable--condensed"
    data-polaris-data-table-nav-hidden-class="Polaris-DataTable"
    data-polaris-data-table-button-disabled-class="Polaris-Button--disabled"
    data-polaris-data-table-indicator-visible-class="Polaris-DataTable__Pip--visible"
  >
    <%= render 'polaris_data_table_navigation', num_columns: 5 %>
    <div class="Polaris-DataTable__ScrollContainer"
      **data-polaris-data-table-target="scrollArea"**
    >
      <table class="Polaris-DataTable__Table">
        <%= render 'sales_by_product_table_header' %>
        <%= render 'sales_by_product_table_body' %>
      </table>
    </div>
  </div>
</div>

Notice the data-controller attribute, and the scrollArea target is defined at this level. We've also defined some classes that will be toggled on elements.

The navigation is where we'll have the left/right arrows, and the visibility indicator dots.

# _polaris_data_table_navigation.html.erb
<div class="Polaris-DataTable__Navigation" style="justify-content: center;">
  <button     
    type="button"
    class="Polaris-Button Polaris-Button--disabled Polaris-Button--plain Polaris-Button--iconOnly"
    disabled=""
    aria-label="Scroll table left one column" 
    data-polaris-data-table-target="leftButton"
    data-action="polaris-data-table#scrollLeft"
  ><span class="Polaris-Button__Content"><span class="Polaris-Button__Icon"><span class="Polaris-Icon"><svg viewBox="0 0 20 20" class="Polaris-Icon__Svg" focusable="false" aria-hidden="true">
            <path d="M12 16a.997.997 0 0 1-.707-.293l-5-5a.999.999 0 0 1 0-1.414l5-5a.999.999 0 1 1 1.414 1.414L8.414 10l4.293 4.293A.999.999 0 0 1 12 16z"></path>
          </svg></span></span></span>
  </button>
  
  <% num_columns.times do %>
    <div class="Polaris-DataTable__Pip"
      data-polaris-data-table-target="columnVisibilityIndicator"
    ></div>
  <% end %>
  
  <button
    type="button"
    class="Polaris-Button Polaris-Button--plain Polaris-Button--iconOnly"
    aria-label="Scroll table right one column"
    data-polaris-data-table-target="rightButton"
    data-action="polaris-data-table#scrollRight"
  ><span class="Polaris-Button__Content"><span class="Polaris-Button__Icon"><span class="Polaris-Icon"><svg viewBox="0 0 20 20" class="Polaris-Icon__Svg" focusable="false" aria-hidden="true">
            <path d="M8 16a.999.999 0 0 1-.707-1.707L11.586 10 7.293 5.707a.999.999 0 1 1 1.414-1.414l5 5a.999.999 0 0 1 0 1.414l-5 5A.997.997 0 0 1 8 16z"></path>
          </svg></span></span></span>
  </button>
</div>

Notice the columnVisibilityIndicator, the left and right buttons and their data-action.

We'll code that all up in the Stimulus controller, which will have the overall structure:

// polaris-data-table-controller.js
import { Controller } from "stimulus"

export default class extends Controller {
  static targets = [ "scrollArea", "leftButton", "rightButton", "columnVisibilityIndicator" ]
  static classes = [ "navShown", "navHidden", "buttonDisabled", "indicatorVisible"]
  
  connect() {
    // start watching the scrollAreaTarget
  }
  
  disconnect() {
    // stop watching the scrollAreaTarget
  }
  
  updateColumnVisibilityIndicators() {
    // ...
  }
  
  updateLeftRightButtonAffordance() {
    // ...
  }
  
  scrollLeft() {
    // ...
  }
  
  scrollRight() {
    // ...
  }
}

We'll need some way of watching the scollArea's position, and what's visible. Unlike Polaris' implementation, we'll use the IntersectionObserver API. No need for window.resize or window.scroll.

So we'll watch the column visibility of the column headings.

# _sales_by_product_table_header.html.erb
<thead>
  <tr>
    <th
      data-polaris-header-cell="true"
      class="Polaris-DataTable__Cell Polaris-DataTable__Cell--verticalAlignTop Polaris-DataTable__Cell--firstColumn Polaris-DataTable__Cell--header"
      scope="col"
      **data-polaris-data-table-target="columnHeading"**
    >Product</th>
    ...
  </tr>
</thead>
// polaris-data-table-controller.js

static targets = [ "scrollArea", **"columnHeading"**, "leftButton", "rightButton", "columnVisibilityIndicator" ]

connect() {
  // We'll be using the IntersectionObserver API.
  // No need for scroll nor resize event handlers.
  this.startObservingColumnVisibility()
}

disconnect() {
  // Discard our intersection observer on removal from the DOM.
  this.stopObservingColumnVisibility()
}
  
startObservingColumnVisibility() {
  if (!'IntersectionObserver' in window &&
    !'IntersectionObserverEntry' in window &&
    !'intersectionRatio' in window.IntersectionObserverEntry.prototype) {
    console.warn(`This browser doesn't support IntersectionObserver`)
    return
  }
  this.intersectionObserver = new IntersectionObserver(this.updateScrollNavigation.bind(this), {
    root: this.scrollAreaTarget,
    threshold: 0.99 // otherwise, the right-most column sometimes won't be considered visible in some browsers, rounding errors, etc.
  })
  
  this.columnHeadingTargets.forEach((headingEl) => {
    this.intersectionObserver.observe(headingEl)
  })
}

stopObservingColumnVisibility() {
  if (!this.intersectionObserver) { return }
  
  this.intersectionObserver.disconnect()
}

When the intersectionObserver instance noticed changes on the visibility of the observed elements, it calls the callback we defined.

Each time the callback is fired, (which also fires by default when intersectionObserver is initialized), we'll update each column heading's data-is-visible attribute, to be checked later by the other callbacks.

// polaris-data-table-controller.js
updateScrollNavigation(observerRecords) {
  observerRecords.forEach(record => {
    record.target.dataset.isVisible = record.isIntersecting
  })
  
  this.toggleScrollNavigationVisibility() // toggle if either first or last isn't in the scrollArea
  this.updateColumnVisibilityIndicators() // set a class on each pip if the associated column is visisble
  this.updateLeftRightButtonAffordance() // disable buttons if scrolled at either end
}

For the full code, see the demo on this Codepen.

Going further: React inside Stimulus

If ever a particular spot in your UI needs the full interactivity of a React component, while still respecting the mostly-server-side approach, a good option is to wrap each instance of the React component within a stimulus controller. Keep your form tag if you need, keep the rest of your page generated server-side. React is there just in that spot.

<div
  data-controller="polaris-react-dropzone"
  data-polaris-react-dropzone-props-value="<%= { type: currency }.to_json.html_safe %>"
></div>
// polaris-react-dropzone-controller.js
connect() {
  // mount the react component from this.element, using this.propsValue as props
}
disconnect() {
  // unmount the react component from this.element
}

Doing it this way will keep your entire page resilient to Turbolinks back button operations.

Summary

While Shopify created a rich React toolset for their own use, they also provide CSS components as an option for those of us who would rather not go all React, all the time.

Using Stimulus, you've got a lightweight option to add the small bits of interactivity just when it's needed.

And as a plus, you can even bolt in full React components just in the spots you need, mounted and unmounted using Stimulus so you can keep the benefits of Turbolinks.

If you'd like to dig more into this layered approach to JavaScript, Pascal Laliberté also wrote the book Modest JS Works. In particular, check out the chapter on The JS Gradient, a way to structure a project to use a gradient of approaches ranging from no-JS to sprinkles, to Stimulus to spot-view-models. {: .guest-intro }

@pascallaliberte
Copy link
Author

Images:

2020-12-16-using-stimulus-with-shopify-polaris-01.gif:

2020-12-16-using-stimulus-with-shopify-polaris-01.gif

2020-12-16-using-stimulus-with-shopify-polaris-02.gif:

2020-12-16-using-stimulus-with-shopify-polaris-02.gif

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment