polaris-turbo-bridge
Version:
Make Shopify Polaris Web Components (<s-button>, <s-link>) navigate with Hotwire Turbo in Rails or any HTML-over-the-wire app.
478 lines (404 loc) • 15.9 kB
JavaScript
// Polaris Turbo Bridge — v0.0.9
// Lets <s-button> and <s-link> Shadow‑DOM elements work with Turbo
// and neutralises Shopify App‑Bridge auto‑redirects.
//
// Zero deps – usable with Import‑Map, esbuild, Vite, etc.
function csrfToken() {
return (
document.querySelector("meta[name='csrf-token']")?.getAttribute("content") ||
""
);
}
function markNoAppRedirect(el) {
// 1 · on the host element
if (
el.getAttribute("data-turbo") !== "false" &&
!el.hasAttribute("data-app-redirect")
) {
el.setAttribute("data-app-redirect", "false");
}
// 2 · inside the shadow anchor (App‑Bridge looks there)
const anchor = el.shadowRoot?.querySelector("a[href]");
if (
anchor &&
anchor.getAttribute("data-turbo") !== "false" &&
!anchor.hasAttribute("data-app-redirect")
) {
anchor.setAttribute("data-app-redirect", "false");
}
}
function submitViaForm(url, method) {
const form = Object.assign(document.createElement("form"), {
action: url,
method: ["get", "post"].includes(method) ? method : "post",
hidden: true,
});
if (!["get", "post"].includes(method)) {
form.insertAdjacentHTML(
"beforeend",
`<input type="hidden" name="_method" value="${method.toUpperCase()}">`
);
}
const token = csrfToken();
if (token) {
form.insertAdjacentHTML(
"beforeend",
`<input type="hidden" name="authenticity_token" value="${token}">`
);
}
document.body.appendChild(form);
form.requestSubmit();
}
export function PolarisTurboBridge(options = {}) {
// Configuration options
const config = {
hideBodyOnNavigation: options.hideBodyOnNavigation || false,
bodyHideClass: options.bodyHideClass || 'hidden',
pageSelector: options.pageSelector || 's-page',
...options
};
const scan = (root) =>
root
.querySelectorAll("s-button[href], s-link[href], s-clickable[href]")
.forEach(markNoAppRedirect);
scan(document); // initial DOM
const mo = new MutationObserver((muts) => {
muts.forEach((m) => {
m.addedNodes.forEach((node) => {
if (node instanceof Element) {
if (node.matches("s-button[href], s-link[href], s-clickable[href]")) markNoAppRedirect(node);
scan(node); // nested
}
});
});
});
mo.observe(document.documentElement, { childList: true, subtree: true });
// Handle form submissions to add loading state to submit buttons
document.addEventListener(
"submit",
(event) => {
const form = event.target;
if (!(form instanceof HTMLFormElement)) return;
// Find s-button elements with type="submit" or variant="primary"
let submitButton = form.querySelector('s-button[type="submit"]');
// If no explicit submit button, look for primary variant
if (!submitButton) {
submitButton = form.querySelector('s-button[variant="primary"]');
}
// If still not found, check if the submitter is an s-button
if (!submitButton && event.submitter) {
const submitterButton = event.submitter.tagName.toLowerCase() === 's-button' ? event.submitter : event.submitter.closest('s-button');
if (submitterButton) {
// Verify it's a submit type or primary variant
const type = submitterButton.getAttribute('type');
const variant = submitterButton.getAttribute('variant');
if (type === 'submit' || variant === 'primary' || (!type && !variant)) {
submitButton = submitterButton;
}
}
}
if (submitButton && submitButton.tagName.toLowerCase() === 's-button') {
submitButton.setAttribute('loading', 'true');
}
// If Shopify App Bridge is available, show global loading state
if (typeof window.shopify !== 'undefined' && window.shopify.loading) {
window.shopify.loading(true);
}
},
true
);
// Remove loading state when Turbo completes navigation
document.addEventListener("turbo:load", () => {
// Remove loading from all buttons and clickables
document.querySelectorAll('s-button[loading="true"], s-clickable[loading="true"]').forEach(el => {
el.removeAttribute('loading');
// Restore original content for s-clickable if it was replaced
if (el.tagName.toLowerCase() === 's-clickable' && el.hasAttribute('data-original-content')) {
el.innerHTML = el.getAttribute('data-original-content');
el.removeAttribute('data-original-content');
}
});
// Remove hidden class from body if it was added
if (config.hideBodyOnNavigation) {
const pageElement = document.body.querySelector(config.pageSelector);
if (pageElement) {
pageElement.classList.remove(config.bodyHideClass);
}
}
// If Shopify App Bridge is available, hide global loading state
if (typeof window.shopify !== 'undefined' && window.shopify.loading) {
window.shopify.loading(false);
}
});
// Also handle turbo:frame-load for frame navigations
document.addEventListener("turbo:frame-load", () => {
// Remove loading from buttons and clickables within the frame
document.querySelectorAll('s-button[loading="true"], s-clickable[loading="true"]').forEach(el => {
el.removeAttribute('loading');
// Restore original content for s-clickable if it was replaced
if (el.tagName.toLowerCase() === 's-clickable' && el.hasAttribute('data-original-content')) {
el.innerHTML = el.getAttribute('data-original-content');
el.removeAttribute('data-original-content');
}
});
});
document.addEventListener(
"click",
(event) => {
const el = event.target.closest("s-button[href], s-link[href], s-clickable[href]");
if (!el) return;
// Skip if already loading
if (el.getAttribute('loading') === 'true') {
event.preventDefault();
return;
}
const url = el.getAttribute("href");
const target = el.getAttribute("target");
// Handle _blank and _top with open
if (target === "_blank" || target === "_top") {
event.preventDefault();
event.stopImmediatePropagation();
open(url, target);
return;
}
// Respect modifier keys / other targets
if (
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
target === "_parent" ||
target === "_self"
)
return;
// Check if already handled
if (event.defaultPrevented) return;
// Mark as handled
event.preventDefault();
event.stopImmediatePropagation(); // <- stop App‑Bridge listeners
const method = (el.dataset.turboMethod || "get").toLowerCase();
const frame = el.dataset.turboFrame;
const confirmText = el.dataset.turboConfirm;
if (confirmText && !confirm(confirmText)) return;
// Check element type
const tagName = el.tagName.toLowerCase();
const isLink = tagName === 's-link';
const needsLoading = (tagName === 's-button' || tagName === 's-clickable') && !frame;
const isDeleteAction = method === 'delete';
// Hide body on navigation if configured - but NOT for delete actions
if (config.hideBodyOnNavigation && !frame && !isDeleteAction && (isLink || tagName === 's-button')) {
const pageElement = document.body.querySelector(config.pageSelector);
if (pageElement) {
pageElement.classList.add(config.bodyHideClass);
}
}
if (needsLoading) {
// For buttons and clickables: show loading state with delay
el.setAttribute('loading', 'true');
// For s-clickable, replace content with spinner
if (tagName === 's-clickable') {
// Store original content to restore later if needed
el.setAttribute('data-original-content', el.innerHTML);
el.innerHTML = '<s-spinner accessibilityLabel="Loading" size="small"></s-spinner>';
}
// Force browser to render the loading state
void el.offsetHeight;
// Small delay to ensure loading spinner is visible
setTimeout(() => {
if (method === "get") {
Turbo.visit(url, { frame });
} else {
submitViaForm(url, method);
}
}, 100);
} else {
// For links and frame navigations: go immediately
if (method === "get") {
Turbo.visit(url, { frame });
} else {
submitViaForm(url, method);
}
}
},
true
);
// Handle breadcrumb links separately
document.addEventListener(
"click",
(event) => {
const breadcrumbLink = event.target.closest('a.Polaris-Breadcrumbs__IconWrapper.Polaris-Breadcrumbs__IconWrapperLink.Polaris-Breadcrumbs__BreadcrumbImageWrapper');
if (!breadcrumbLink) return;
const href = breadcrumbLink.getAttribute('href');
if (!href) return;
// Check for modifier keys
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
// Prevent default navigation and stop all propagation
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
// Hide body if configured
if (config.hideBodyOnNavigation) {
const pageElement = document.body.querySelector(config.pageSelector);
if (pageElement) {
pageElement.classList.add(config.bodyHideClass);
}
}
// Navigate with Turbo after a small delay
setTimeout(() => {
Turbo.visit(href);
}, 50);
},
true
);
// Handle ui-title-bar breadcrumb buttons
document.addEventListener(
"click",
(event) => {
const button = event.target.closest('button[variant="breadcrumb"]');
if (!button) return;
// Check if button is in ui-title-bar
const titleBar = button.closest('ui-title-bar');
if (!titleBar) return;
// Breadcrumb buttons are navigation, so add hidden class
if (config.hideBodyOnNavigation) {
const pageElement = document.body.querySelector(config.pageSelector);
if (pageElement) {
pageElement.classList.add(config.bodyHideClass);
}
}
},
true
);
// Handle ui-title-bar s-button links
document.addEventListener(
"click",
(event) => {
const sButtonLink = event.target.closest('a.s-button');
if (!sButtonLink) return;
// Check if link is in ui-title-bar
const titleBar = sButtonLink.closest('ui-title-bar');
if (!titleBar) return;
const href = sButtonLink.getAttribute('href');
if (!href) return;
// Check if this is a delete action
const turboMethod = sButtonLink.getAttribute('data-turbo-method');
const isDeleteAction = turboMethod === 'delete';
// Check for modifier keys
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
// For delete actions, let Turbo/Rails handle it naturally
// Don't add hidden class and don't prevent default
if (isDeleteAction) {
return;
}
// For non-delete navigation, prevent default and add hidden class
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
// Add hidden class if configured
if (config.hideBodyOnNavigation) {
const pageElement = document.body.querySelector(config.pageSelector);
if (pageElement) {
pageElement.classList.add(config.bodyHideClass);
}
}
// Navigate with Turbo
setTimeout(() => {
Turbo.visit(href);
}, 50);
},
true
);
// Handle title bar buttons (for App Bridge transformed buttons)
document.addEventListener(
"click",
(event) => {
const button = event.target.closest('button');
if (!button) return;
// Check if button is in old-style title bar (App Bridge transformed)
const titleBar = button.closest('._TitleBar_6rj2k_1, [class*="TitleBar"], ._ActionsMobileLayout_g9ncd_1');
if (!titleBar) return;
// Skip breadcrumb buttons as they're handled separately
if (button.hasAttribute('variant') && button.getAttribute('variant') === 'breadcrumb') return;
// Get button text (handle nested spans)
const buttonText = button.textContent?.toLowerCase() || '';
// Check if this is a delete button (by text or data attributes)
const turboMethod = button.getAttribute('data-turbo-method');
const isDeleteButton = turboMethod === 'delete' || buttonText.includes('delete');
// Skip post buttons
if (buttonText.includes('post')) return;
// Skip if button is a dropdown trigger or has aria-expanded
if (button.hasAttribute('aria-expanded') || button.hasAttribute('aria-controls')) return;
// Skip if no navigation is expected (icon-only buttons without clear purpose)
if (button.classList.contains('Polaris-Button--iconOnly') &&
!button.hasAttribute('href') &&
!button.hasAttribute('data-href') &&
!button.getAttribute('onclick')?.includes('Turbo.visit')) return;
// Check if button has onclick with Turbo.visit or data-href
const onclickAttr = button.getAttribute('onclick');
const hasTurboVisit = onclickAttr && onclickAttr.includes('Turbo.visit');
const dataHref = button.getAttribute('data-href');
// For buttons that might be transformed from s-button links
// App Bridge may have added onclick handlers
const hasAppBridgeClick = onclickAttr && !hasTurboVisit;
// If this looks like a navigation button (not delete), add hidden class
if (config.hideBodyOnNavigation && !isDeleteButton) {
// Only add hidden class for non-delete buttons
const pageElement = document.body.querySelector(config.pageSelector);
if (pageElement) {
pageElement.classList.add(config.bodyHideClass);
}
}
// Only intercept and handle our own navigation if it's a Turbo.visit or data-href
if (hasTurboVisit || dataHref) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
// Extract URL
let url;
if (hasTurboVisit) {
const match = onclickAttr.match(/Turbo\.visit\(['"]([^'"]+)['"]\)/);
url = match ? match[1] : null;
} else if (dataHref) {
url = dataHref;
}
// Navigate if we have a URL
if (url) {
setTimeout(() => {
Turbo.visit(url);
}, 50);
}
}
// Otherwise, let App Bridge handle the click naturally
},
true
);
// Handle any button with Turbo.visit in onclick (not just title bar)
document.addEventListener(
"click",
(event) => {
const turboButton = event.target.closest('button[onclick*="Turbo.visit"]');
if (!turboButton) return;
// Skip if already handled by title bar handler
if (turboButton.closest('._TitleBar_6rj2k_1, [class*="TitleBar"], ._ActionsMobileLayout_g9ncd_1')) return;
// Add hidden class if configured
if (config.hideBodyOnNavigation) {
const pageElement = document.body.querySelector(config.pageSelector);
if (pageElement) {
pageElement.classList.add(config.bodyHideClass);
}
}
},
true
);
}
if (typeof window !== "undefined" && window.POLARIS_TURBO_AUTOSTART) {
if (typeof window.Turbo !== "undefined") {
// Check for configuration in window object
const config = window.POLARIS_TURBO_CONFIG || {};
PolarisTurboBridge(config);
} else {
console.warn(
"[polaris-turbo-bridge] POLARIS_TURBO_AUTOSTART is true, " +
"but Turbo is not loaded; bridge disabled."
);
}
}