Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Show a spinner while a form post is in flight

3a068983ae40271962428eff5e2568446b36299d · Keenan Nemetz · 11d ago · parent 628d4523

server/web/static/app.js +70

@@ -52,6 +52,76 @@ const copyText = (value, done) => {
52 52 });
53 53 })();
54 54
55 // A form post is a server round trip that can take a while: landing a change
56 // request writes the merge, a repository rename moves the store. The submit
57 // button doubles as the progress indicator. It is disabled before the request
58 // leaves, so a second click cannot run the same mutation twice, and after a short
59 // delay its icon gives way to a spinner. The delay keeps a fast save from
60 // flashing a loader. A successful write answers with a redirect or a new page,
61 // so the next page is the reset; pageshow puts the buttons back when a back
62 // navigation restores this page from the back/forward cache. GET forms are left
63 // alone: they search and filter, and do not need a wait state. A form with
64 // hx-post runs through HTMX, which has its own disabled-element handling, and a
65 // form with data-no-busy opts out.
66 (() => {
67 const DELAY = 150;
68
69 const reset = (root = document) => {
70 if (!root || typeof root.querySelectorAll !== 'function') return;
71 root.querySelectorAll('button[data-loading]').forEach((button) => {
72 if (button.loadingTimer) {
73 clearTimeout(button.loadingTimer);
74 button.loadingTimer = 0;
75 }
76 button.removeAttribute('data-loading');
77 button.removeAttribute('aria-busy');
78 button.classList.remove('spinning');
79 button.disabled = false;
80 const mark = button.firstElementChild;
81 if (mark && mark.classList.contains('spinner')) mark.remove();
82 });
83 };
84
85 const begin = (button) => {
86 button.disabled = true;
87 button.setAttribute('aria-busy', 'true');
88 // Set the marker now so a cancel that arrives before the spinner is due can
89 // still find and restore the button. Only the spinner waits for DELAY.
90 button.setAttribute('data-loading', '');
91 button.loadingTimer = setTimeout(() => {
92 button.loadingTimer = 0;
93 const mark = document.createElement('span');
94 mark.className = 'spinner';
95 mark.setAttribute('aria-hidden', 'true');
96 button.classList.add('spinning');
97 button.insertBefore(mark, button.firstChild);
98 }, DELAY);
99 };
100
101 document.addEventListener('submit', (event) => {
102 const form = event.target;
103 if (!(form instanceof HTMLFormElement)) return;
104 if (form.method.toLowerCase() !== 'post') return;
105 if (form.hasAttribute('data-no-busy')) return;
106 if (form.hasAttribute('hx-post') || form.hasAttribute('hx-put') ||
107 form.hasAttribute('hx-patch') || form.hasAttribute('hx-delete')) return;
108 // The confirmation handler above has already had its say. Checking here also
109 // covers any other listener that runs before this one and takes over.
110 if (event.defaultPrevented) return;
111 const buttons = Array.from(form.querySelectorAll('button[type="submit"], button:not([type])'))
112 .filter((button) => !button.disabled);
113 if (!buttons.length) return;
114 buttons.forEach(begin);
115 // A listener registered after this one may still cancel the submission.
116 // If it does, put the buttons back rather than leaving them disabled.
117 setTimeout(() => {
118 if (event.defaultPrevented) reset(form);
119 }, 0);
120 });
121
122 window.addEventListener('pageshow', () => reset());
123 })();
124
55 125 // Syntax highlighting. The server sends each blob and each diff side as plain
56 126 // lines, so the page is complete and scrollable before this runs; the tokens come
57 127 // from a worker (static/prism-worker.js) so a large file never blocks the main