Skip to content

Instantly share code, notes, and snippets.

@fabianeichinger
Last active April 14, 2024 13:17
Show Gist options
  • Save fabianeichinger/ec5018a313b2bb79513286ca27c53ced to your computer and use it in GitHub Desktop.
Save fabianeichinger/ec5018a313b2bb79513286ca27c53ced to your computer and use it in GitHub Desktop.
htmx extension for Shopify Ajax API

htmx-ext-shopify gives you access to the Shopify Ajax API from HTML to progressively enhance your Liquid templates. It's an unofficial, experimental extension for htmx.

The extension manipulates API requests and responses in order to replace / update Liquid sections on the current page. The new HTML for sections comes from Bundled section rendering and inserted using htmx swapping and out of band swaps.

This behavior is controlled using existing and new (see below) hx-* attributes on the form triggering the request.

Current features

Update the customer's cart through the Shopify Ajax API and update the current page in response.

Installation

htmx must be loaded before htmx-ext-shopify is loaded. It's currently tested with htmx v1.7.0.

You can simply vendor both htmx.min.js and htmx-ext-shopify.js by adding them to the assets/ folder in your Shopify Liquid theme. Then add <script> tags for both to your theme's layout file, usually layout/theme.liquid.

<script ...other js files for your theme
<script src="{{ 'htmx.min.js' | asset_url }}" defer></script>
<script src="{{ 'htmx-ext-shopify.js' | asset_url }}" defer></script>

New Attributes and Helpers

  • hx-swap-section The id of the Liquid section that shall be requested and used for swapping.
  • hx-oob-sections Comma-separated list of ids of Liquid sections that shall be requested and used for out of band swaps.
  • In urls from hx-post, hx-get,… the string /{locale}/ is automatically replaced with the locale-aware root.

Troubleshooting

  • My form is not handled by htmx.
    • Make sure your attributes are set correctly.
    • If the form is inserted by JS into the DOM you might have to call htmx.process(el).
  • My form / section disappears after submit but no replacement is inserted.
    • Make sure the section id in hx-swap-section is correct.
    • Bundled section rendering doesn't work when accessing the theme on 127.0.0.1 through the Shopify CLI development server for some reason. Use the theme preview mode on the proper store domain instead.

Example

Lets build an add to cart form for products that will replace itself with a success message instead of navigating to the cart page.

  1. Create a new section file sections/ex-product-add.liquid in your theme source. As a baseline copy the following code.
{%- liquid
  assign variant = product.selected_or_first_available_variant
-%}

<h3>Add this product!</h3>
<form
  action="{{ routes.cart_add_url }}"
  method="post"
>
  <input name="items[0][id]" value="{{ variant.id }}" hidden>
  <label for="ex-quantity">QTY</label>
  <input id="ex-quantity" name="items[0][quantity]" type="number" value="1">
  <button type="submit">Add to cart</button>
</form>

{% schema %}
{
  "name": "Example Product Add",
  "settings": [],
  "presets": [{
    "name": "Example Product Add"
  }],
  "templates": [
    "product"
  ]
}
{% endschema %}
  1. Add the section to your product page. If your theme is using JSON templates you can do it through the theme editor. Otherwise add {% section 'ex-product-add' %} to your templates/product.liquid file.

  2. Test the form on a product page. Clicking the "Add to cart" should redirect to the cart page and the entered quantity should be in there.

  3. Install htmx and htmx-ext-shopify as explained above.

  4. Reference the extension in the form tag and declare that htmx should POST the form to the add to cart Ajax API.

…
<form
  action="{{ routes.cart_add_url }}"
  method="post"
  hx-ext="shopify"
  hx-post="/{locale}/cart/add.js"
>
…

The form will just disappear now after submitting instead of navigating. You can check that the item is still added to the cart though.

  1. Define the replacement for the form after submit. Create a new file sections/ex-product-add-success.liquid with the following content.
<h3>Product added!</h3>
<p><a href="{{ routes.cart_url }}">Go to cart</a></p>

Also update the form attributes in sections/ex-product-add.liquid.

<form
  action="{{ routes.cart_add_url }}"
  method="post"
  hx-ext="shopify"
  hx-post="/{locale}/cart/add.js"
  hx-swap-section="ex-product-add-success"
  hx-target="#shopify-section-{{ section.id }}"
  hx-swap="outerHTML show:top"
>

Submitting will now request the HTML for the ex-product-add-success section with the API call.

Note that we also defined hx-target and hx-swap="outerHTML …" so the entire ex-product-add section is replaced. The default behavior would be to replace the innerHTML of the form with the success HTML.

  1. Update sections with cart item counters etc. on the current page. Update the form tag by adding the hx-oob-sections attribute.
<form
  action="{{ routes.cart_add_url }}"
  method="post"
  hx-ext="shopify"
  hx-post="/{locale}/cart/add.js"
  hx-swap-section="ex-product-add-success"
  hx-target="#shopify-section-{{ section.id }}"
  hx-swap="outerHTML show:top"
  hx-oob-sections="announcement-bar,cart"
>

The section ids for this attribute depend on your theme. Look for HTML id attributes that start with shopify-section- on parents of elements that need to update.

You can copy the complete examples from the files below

<h3>Product added!</h3>
<p><a href="{{ routes.cart_url }}">Go to cart</a></p>
{%- liquid
assign variant = product.selected_or_first_available_variant
-%}
<h3>Add this product!</h3>
<form
action="{{ routes.cart_add_url }}"
method="post"
hx-ext="shopify"
hx-post="/{locale}/cart/add.js"
hx-swap-section="ex-product-add-success"
hx-target="#shopify-section-{{ section.id }}"
hx-swap="outerHTML show:top"
hx-oob-sections="announcement-bar,cart"
>
<input name="items[0][id]" value="{{ variant.id }}" hidden>
<label for="ex-quantity">QTY</label>
<input id="ex-quantity" name="items[0][quantity]" type="number" value="1">
<button type="submit">Add to cart</button>
</form>
{% schema %}
{
"name": "Example Product Add",
"settings": [],
"presets": [{
"name": "Example Product Add"
}],
"templates": [
"product"
]
}
{% endschema %}
(function () {
var api;
function getSwapSectionSpec(elt) {
var attr = api.getAttributeValue(elt, "hx-swap-section");
if (attr) {
return attr;
}
return null;
}
function getOobSectionSpecs(elt) {
var attr = api.getAttributeValue(elt, "hx-oob-sections");
if (attr) {
return Object.fromEntries(attr.split(",").map((id) => [id, null]));
}
return null;
}
htmx.defineExtension("shopify", {
init: function (apiRef) {
api = apiRef;
return null;
},
onEvent: function (name, evt) {
if (name === "htmx:configRequest") {
evt.detail.path = evt.detail.path.replace(
"/{locale}/",
window.Shopify.routes.root
);
}
return true;
},
transformResponse: function (text, xhr, elt) {
var res = JSON.parse(text);
text = "";
if (res.sections) {
var swapSectionSpec = getSwapSectionSpec(elt);
if (swapSectionSpec) {
var swapSectionId = swapSectionSpec;
if (res.sections[swapSectionId]) {
text += res.sections[swapSectionId];
}
}
var oobSectionSpecs = getOobSectionSpecs(elt);
if (oobSectionSpecs) {
for (var sectionId in oobSectionSpecs) {
if (res.sections[sectionId]) {
var sectionHtml = res.sections[sectionId];
sectionHtml = sectionHtml.replace(" ", " hx-swap-oob=true ");
text += sectionHtml;
}
}
}
}
return text;
},
isInlineSwap: function (swapStyle) {
return false;
},
handleSwap: function (swapStyle, target, fragment, settleInfo) {
return false;
},
encodeParameters: function (xhr, parameters, elt) {
var sectionIds = [];
var swapSectionSpec = getSwapSectionSpec(elt);
if (swapSectionSpec) {
var swapSectionId = swapSectionSpec;
sectionIds.push(swapSectionId);
}
var oobSectionSpecs = getOobSectionSpecs(elt);
if (oobSectionSpecs) {
var oobSectionIds = Object.keys(oobSectionSpecs);
sectionIds = sectionIds.concat(oobSectionIds);
}
parameters["sections"] = sectionIds.join(",");
},
});
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment