Skip to content

Instantly share code, notes, and snippets.

@BenQoder
Last active July 14, 2025 18:54
Show Gist options
  • Select an option

  • Save BenQoder/2d31e6b8ca891bbc46965a20cb35936a to your computer and use it in GitHub Desktop.

Select an option

Save BenQoder/2d31e6b8ca891bbc46965a20cb35936a to your computer and use it in GitHub Desktop.
App Loading Attribute Extensions

App Loading Attribute Extensions

This script provides a declarative way to control loading indicators and CSS classes for Unpoly-enabled forms and links using custom attributes prefixed with app-loading-.
You can show/hide elements, add/remove classes, and control delays for loading states.

Features

  • app-loading-show: Show elements when loading starts (custom display mode)
  • app-loading-hide: Hide elements when loading starts
  • app-loading-add: Add a class to elements when loading starts, remove when loading ends
  • app-loading-remove: Remove a class from elements when loading starts, add back when loading ends
  • app-loading-delay: Control delay (in ms) before applying start/end loading effects

All attributes can be used independently or in combination.


Usage

1. Define your loaders and targets

<!-- Loader element -->
<div id="loader" class="loader">Loading...</div>
<!-- Button that will be hidden -->
<button id="button" class="ready">Submit</button>
<!-- Form that triggers loading state -->
<form up-submit 
      app-loading-show="#loader, flex" 
      app-loading-hide="#button"
      app-loading-add="#loader, spinning"
      app-loading-remove="#button, ready">
  <!-- ... -->
</form>

What happens:

  • When form is submitted, #loader is shown (display: flex), #button is hidden, spinning class is added to #loader, and ready class is removed from #button.
  • When loading ends, all changes are reverted.

2. Adding a spinner by class

<div class="spinner"></div>
<form up-submit app-loading-show=".spinner, block">
  <!-- ... -->
</form>

3. Multiple selectors

<div id="loader"></div>
<div class="overlay"></div>
<form up-submit app-loading-show="#loader .overlay, flex">
  <!-- ... -->
</form>

4. Delays

<!-- 1s delay before resetting (end), 5s delay before starting -->
<form up-submit app-loading-show="#loader, flex" app-loading-delay="1000, 5000">
  <!-- ... -->
</form>

<!-- Only delay at end (after loading finishes) -->
<form up-submit app-loading-add="#loader, loading" app-loading-delay="1500">
  <!-- ... -->
</form>

<!-- No end delay, 2s start delay -->
<form up-submit app-loading-hide="#loader" app-loading-delay="0, 2000">
  <!-- ... -->
</form>

Attribute Syntax

Each attribute value is a comma-separated pair:

  • First part: CSS selector(s) (IDs, classes, complex selectors, etc.)
  • Second part:
    • For show/hide: display value (e.g., block, flex, none)
    • For add/remove: class name

Examples

Attribute Example Value Meaning
app-loading-show #loader, flex Show #loader as display: flex when loading
app-loading-hide .btn, none Hide .btn (display: none) when loading
app-loading-add #form, busy Add class busy to #form when loading
app-loading-remove .ready, ready Remove class ready from .ready when loading
app-loading-delay 1000, 5000 1s end delay, 5s start delay
app-loading-delay 1500 1.5s end delay, no start delay

How It Works

  • On start event (form submit or link follow):
    • Applies show/hide/class additions/removals (after optional start delay)
  • On end event (fragment loaded, offline, aborted):
    • Resets show/hide/class changes (after optional end delay)

All changes are reverted after the loading finishes.


License

MIT

// @ts-nocheck
up.compiler('[app-loading-show], [app-loading-hide], [app-loading-add], [app-loading-remove]', function (element) {
function parseAttr(attr, defaultValue) {
if (!attr) return null;
const parts = attr.split(',').map(p => p.trim());
return {
selectors: parts[0],
modifier: (parts[1] || defaultValue)
};
}
function parseDelayAttr(attr) {
if (!attr) return { end: 0, start: 0 };
const parts = attr.split(',').map(p => p.trim());
const endDelay = parseInt(parts[0], 10) || 0;
const startDelay = parts[1] ? (parseInt(parts[1], 10) || 0) : 0;
return { end: endDelay, start: startDelay };
}
const showConfig = parseAttr(element.getAttribute('app-loading-show'), 'block');
const hideConfig = parseAttr(element.getAttribute('app-loading-hide'), 'none');
const addConfig = parseAttr(element.getAttribute('app-loading-add'), '');
const removeConfig = parseAttr(element.getAttribute('app-loading-remove'), '');
const delayConfig = parseDelayAttr(element.getAttribute('app-loading-delay'));
const startEvent = element.matches('form') ? 'up:form:submit' : 'up:link:follow';
let isLoading = false;
let startTimeout = null;
let endTimeout = null;
let resetTimeout = null;
// Store original states
const originalStates = new Map();
function storeOriginalState(el, type) {
const key = `${el.tagName}-${el.id || el.className}`;
if (!originalStates.has(key)) {
if (type === 'display') {
originalStates.set(key, {
display: el.style.display || null
});
}
}
}
function applyLoadingState() {
if (isLoading) return;
isLoading = true;
// Clear any existing timeouts
if (resetTimeout) {
clearTimeout(resetTimeout);
resetTimeout = null;
}
if (startTimeout) {
clearTimeout(startTimeout);
startTimeout = null;
}
const run = () => {
if (showConfig?.selectors) {
up.fragment.all(showConfig.selectors, { layer: 'any' }).forEach(el => {
storeOriginalState(el, 'display');
el.style.display = showConfig.modifier;
});
}
if (hideConfig?.selectors) {
up.fragment.all(hideConfig.selectors, { layer: 'any' }).forEach(el => {
storeOriginalState(el, 'display');
el.style.display = hideConfig.modifier;
});
}
if (addConfig?.selectors && addConfig?.modifier) {
up.fragment.all(addConfig.selectors, { layer: 'any' }).forEach(el => {
el.classList.add(addConfig.modifier);
});
}
if (removeConfig?.selectors && removeConfig?.modifier) {
up.fragment.all(removeConfig.selectors, { layer: 'any' }).forEach(el => {
el.classList.remove(removeConfig.modifier);
});
}
};
if (delayConfig.start > 0) {
startTimeout = setTimeout(run, delayConfig.start);
} else {
run();
}
// Failsafe timeout - always reset after 15 seconds
resetTimeout = setTimeout(() => {
resetLoadingState();
}, 15000);
}
function resetLoadingState() {
if (!isLoading) return;
// Clear all timeouts
if (resetTimeout) {
clearTimeout(resetTimeout);
resetTimeout = null;
}
if (startTimeout) {
clearTimeout(startTimeout);
startTimeout = null;
}
if (endTimeout) {
clearTimeout(endTimeout);
endTimeout = null;
}
const run = () => {
// Reset all elements, even if they were replaced
if (showConfig?.selectors) {
up.fragment.all(showConfig.selectors, { layer: 'any' }).forEach(el => {
const key = `${el.tagName}-${el.id || el.className}`;
const original = originalStates.get(key);
if (original && original.display !== null) {
el.style.display = original.display;
} else {
el.style.removeProperty('display');
}
});
}
if (hideConfig?.selectors) {
up.fragment.all(hideConfig.selectors, { layer: 'any' }).forEach(el => {
const key = `${el.tagName}-${el.id || el.className}`;
const original = originalStates.get(key);
if (original && original.display !== null) {
el.style.display = original.display;
} else {
el.style.removeProperty('display');
}
});
}
if (addConfig?.selectors && addConfig?.modifier) {
up.fragment.all(addConfig.selectors, { layer: 'any' }).forEach(el => {
el.classList.remove(addConfig.modifier);
});
}
if (removeConfig?.selectors && removeConfig?.modifier) {
up.fragment.all(removeConfig.selectors, { layer: 'any' }).forEach(el => {
el.classList.add(removeConfig.modifier);
});
}
isLoading = false;
originalStates.clear();
};
if (delayConfig.end > 0) {
endTimeout = setTimeout(run, delayConfig.end);
} else {
run();
}
}
// Apply loading state when action starts
element.addEventListener(startEvent, (event) => {
// Store the target so we can track when it completes
if (event.renderOptions) {
event.renderOptions.onLoaded = () => {
resetLoadingState();
};
event.renderOptions.onOffline = () => {
resetLoadingState();
};
event.renderOptions.onAborted = () => {
resetLoadingState();
};
}
applyLoadingState();
});
// Also listen for completion events on the element itself
const handleCompletionEvent = (event) => {
if (isLoading) {
resetLoadingState();
}
};
up.on(element, 'up:form:loaded', handleCompletionEvent);
up.on(element, 'up:form:aborted', handleCompletionEvent);
up.on(element, 'up:form:offline', handleCompletionEvent);
// Cleanup on element destruction
up.destructor(element, function () {
// Clear all timeouts
if (resetTimeout) clearTimeout(resetTimeout);
if (startTimeout) clearTimeout(startTimeout);
if (endTimeout) clearTimeout(endTimeout);
// Force reset if still loading
if (isLoading) {
// Don't wait for delays during destruction
const savedEndDelay = delayConfig.end;
delayConfig.end = 0;
resetLoadingState();
delayConfig.end = savedEndDelay;
}
});
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment