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 offers two options for their Polaris component system:
- React Components, and
- 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.
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.
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.
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.
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.
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.
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 }
Images:
2020-12-16-using-stimulus-with-shopify-polaris-01.gif
:2020-12-16-using-stimulus-with-shopify-polaris-02.gif
: