Replace move spinner with progress bar

The full-screen `withLoadingIndicator` overlay was heavy feedback for a
small reorder. Moves now drive the Turbo progress bar through a new
`withProgressBar` helper, mark the affected lists with `aria-busy`, and
dim only the inner `.Box-list`. The fade honours
`prefers-reduced-motion`; the root keeps `data-sortable-lists-moving` as
the cross-controller drag guard.
This commit is contained in:
Alexander Brandon Coles
2026-06-13 14:07:03 +01:00
parent 69ad88f1c1
commit 7e3453b84c
5 changed files with 70 additions and 17 deletions
@@ -49,6 +49,15 @@
container-name: backlogsListsContainer container-name: backlogsListsContainer
container-type: inline-size container-type: inline-size
[data-sortable-lists-target~="list"][aria-busy="true"]
cursor: progress
.Box-list
opacity: 0.7
@media (prefers-reduced-motion: no-preference)
transition: opacity 0.1s ease-in-out
.op-sprint-planning-container .op-sprint-planning-container
display: flex display: flex
flex-direction: row flex-direction: row
@@ -51,7 +51,7 @@ describe('Sortable lists controller', () => {
let ctx:StimulusTestContext; let ctx:StimulusTestContext;
let fixture:HTMLElement; let fixture:HTMLElement;
let fetchMock:ReturnType<typeof vi.fn>; let fetchMock:ReturnType<typeof vi.fn>;
let loadingIndicator:HTMLElement; let turboHelpers:typeof import('core-turbo/helpers');
let renderStreamMessageMock:ReturnType<typeof vi.fn>; let renderStreamMessageMock:ReturnType<typeof vi.fn>;
beforeAll(async () => { beforeAll(async () => {
@@ -69,6 +69,8 @@ describe('Sortable lists controller', () => {
({ autoScrollForElements } = await import('@atlaskit/pragmatic-drag-and-drop-auto-scroll/element')); ({ autoScrollForElements } = await import('@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'));
({ default: SortableListsController } = await import('./sortable-lists.controller')); ({ default: SortableListsController } = await import('./sortable-lists.controller'));
({ sortableItemData, sortableListData } = await import('./sortable-lists/drag-and-drop')); ({ sortableItemData, sortableListData } = await import('./sortable-lists/drag-and-drop'));
turboHelpers = await import('core-turbo/helpers');
}); });
function input() { function input() {
@@ -220,10 +222,8 @@ describe('Sortable lists controller', () => {
renderStreamMessage: renderStreamMessageMock, renderStreamMessage: renderStreamMessageMock,
}); });
loadingIndicator = document.createElement('div'); vi.spyOn(turboHelpers.TurboHelpers, 'showProgressBar').mockImplementation(() => undefined);
loadingIndicator.id = 'global-loading-indicator'; vi.spyOn(turboHelpers.TurboHelpers, 'hideProgressBar').mockImplementation(() => undefined);
loadingIndicator.hidden = true;
document.body.appendChild(loadingIndicator);
ctx = await setupStimulusTest({ ctx = await setupStimulusTest({
controllers: { controllers: {
@@ -235,7 +235,7 @@ describe('Sortable lists controller', () => {
afterEach(() => { afterEach(() => {
ctx.dispose(); ctx.dispose();
loadingIndicator.remove(); vi.restoreAllMocks();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
@@ -337,7 +337,7 @@ describe('Sortable lists controller', () => {
expect(fetchMock).not.toHaveBeenCalled(); expect(fetchMock).not.toHaveBeenCalled();
}); });
it('marks the sortable lists root and global loading indicator while moving an item', async () => { it('marks the moving state and busy lists and shows the progress bar while moving an item', async () => {
let resolveMove:(response:Response) => void; let resolveMove:(response:Response) => void;
fetchMock.mockImplementationOnce(() => { fetchMock.mockImplementationOnce(() => {
@@ -352,15 +352,15 @@ describe('Sortable lists controller', () => {
await dropCurrentItemOnList(firstSourceItem, targetList); await dropCurrentItemOnList(firstSourceItem, targetList);
expect(root.dataset.sortableListsMoving).toEqual('true'); expect(root.dataset.sortableListsMoving).toEqual('true');
expect(root.getAttribute('aria-busy')).toEqual('true'); expect(targetList.getAttribute('aria-busy')).toEqual('true');
expect(loadingIndicator.hidden).toBe(false); expect(turboHelpers.TurboHelpers.showProgressBar).toHaveBeenCalled();
resolveMove!(new Response('', { status: 200 })); resolveMove!(new Response('', { status: 200 }));
await flushPromises(); await flushPromises();
expect(root.hasAttribute('data-sortable-lists-moving')).toBe(false); expect(root.hasAttribute('data-sortable-lists-moving')).toBe(false);
expect(root.hasAttribute('aria-busy')).toBe(false); expect(targetList.hasAttribute('aria-busy')).toBe(false);
expect(loadingIndicator.hidden).toBe(true); expect(turboHelpers.TurboHelpers.hideProgressBar).toHaveBeenCalled();
}); });
it('rejects new sortable-list drags and drops while a move is pending', async () => { it('rejects new sortable-list drags and drops while a move is pending', async () => {
@@ -410,7 +410,7 @@ describe('Sortable lists controller', () => {
type: 'error', type: 'error',
})); }));
expect(root.hasAttribute('data-sortable-lists-moving')).toBe(false); expect(root.hasAttribute('data-sortable-lists-moving')).toBe(false);
expect(loadingIndicator.hidden).toBe(true); expect(turboHelpers.TurboHelpers.hideProgressBar).toHaveBeenCalled();
window.removeEventListener('op:toasters:add', onToast); window.removeEventListener('op:toasters:add', onToast);
}); });
@@ -36,7 +36,7 @@ import { Controller } from '@hotwired/stimulus';
import { FetchRequest } from '@rails/request.js'; import { FetchRequest } from '@rails/request.js';
import { debugLog } from 'core-app/shared/helpers/debug_output'; import { debugLog } from 'core-app/shared/helpers/debug_output';
import { flipMove } from 'core-stimulus/helpers/flip-helper'; import { flipMove } from 'core-stimulus/helpers/flip-helper';
import { withLoadingIndicator } from 'core-stimulus/helpers/request-helpers'; import { withProgressBar } from 'core-stimulus/helpers/request-helpers';
import { parseTemplate } from 'url-template'; import { parseTemplate } from 'url-template';
import { import {
acceptsSortableItemType, acceptsSortableItemType,
@@ -253,7 +253,7 @@ export default class SortableListsController extends Controller<HTMLElement> {
this.setMoving(true); this.setMoving(true);
try { try {
const response = await withLoadingIndicator(request.perform()); const response = await withProgressBar(request.perform());
if (!response.ok) { if (!response.ok) {
debugLog(`Failed to move sortable list item: ${response.statusCode}`); debugLog(`Failed to move sortable list item: ${response.statusCode}`);
@@ -273,10 +273,10 @@ export default class SortableListsController extends Controller<HTMLElement> {
private setMoving(moving:boolean):void { private setMoving(moving:boolean):void {
if (moving) { if (moving) {
this.element.setAttribute(sortableListsMovingAttribute, 'true'); this.element.setAttribute(sortableListsMovingAttribute, 'true');
this.element.setAttribute('aria-busy', 'true'); this.listTargets.forEach((list) => list.setAttribute('aria-busy', 'true'));
} else { } else {
this.element.removeAttribute(sortableListsMovingAttribute); this.element.removeAttribute(sortableListsMovingAttribute);
this.element.removeAttribute('aria-busy'); this.listTargets.forEach((list) => list.removeAttribute('aria-busy'));
} }
} }
@@ -27,8 +27,9 @@
//++ //++
import type { FetchResponse } from '@rails/request.js'; import type { FetchResponse } from '@rails/request.js';
import { TurboHelpers } from 'core-turbo/helpers';
import { withLoadingIndicator } from './request-helpers'; import { withLoadingIndicator, withProgressBar } from './request-helpers';
describe('withLoadingIndicator', () => { describe('withLoadingIndicator', () => {
let indicator:HTMLElement|null; let indicator:HTMLElement|null;
@@ -75,3 +76,37 @@ describe('withLoadingIndicator', () => {
expect(() => withLoadingIndicator(Promise.resolve({} as FetchResponse))).toThrow(); expect(() => withLoadingIndicator(Promise.resolve({} as FetchResponse))).toThrow();
}); });
}); });
describe('withProgressBar', () => {
beforeEach(() => {
vi.spyOn(TurboHelpers, 'showProgressBar').mockImplementation(() => undefined);
vi.spyOn(TurboHelpers, 'hideProgressBar').mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('shows the progress bar while the request is pending and hides it on success', async () => {
let resolveRequest!:(response:FetchResponse) => void;
const request = new Promise<FetchResponse>((resolve) => { resolveRequest = resolve; });
const wrapped = withProgressBar(request);
expect(TurboHelpers.showProgressBar).toHaveBeenCalledOnce();
expect(TurboHelpers.hideProgressBar).not.toHaveBeenCalled();
resolveRequest({} as FetchResponse);
await wrapped;
expect(TurboHelpers.hideProgressBar).toHaveBeenCalledOnce();
});
it('hides the progress bar when the request rejects and propagates the rejection', async () => {
const error = new Error('boom');
const wrapped = withProgressBar(Promise.reject(error));
await expect(wrapped).rejects.toBe(error);
expect(TurboHelpers.hideProgressBar).toHaveBeenCalledOnce();
});
});
@@ -30,6 +30,7 @@
import { FetchRequest, FetchResponse, Options } from '@rails/request.js'; import { FetchRequest, FetchResponse, Options } from '@rails/request.js';
import { hideElement, showElement } from 'core-app/shared/helpers/dom-helpers'; import { hideElement, showElement } from 'core-app/shared/helpers/dom-helpers';
import { TurboHelpers } from 'core-turbo/helpers';
import invariant from 'tiny-invariant'; import invariant from 'tiny-invariant';
export function post(url:string|URL, options?:Options) { export function post(url:string|URL, options?:Options) {
@@ -47,6 +48,14 @@ export function withLoadingIndicator(request:Promise<FetchResponse>) {
}); });
} }
export function withProgressBar(request:Promise<FetchResponse>) {
TurboHelpers.showProgressBar();
return request.finally(() => {
TurboHelpers.hideProgressBar();
});
}
export class FetchRequestError extends Error { export class FetchRequestError extends Error {
constructor(public _errorCode:number, message = 'HTTP Error', options:ErrorOptions = {}) { constructor(public _errorCode:number, message = 'HTTP Error', options:ErrorOptions = {}) {
super(message, options); super(message, options);