mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 11:17:07 +00:00
fix(ui): show delayed busy feedback on confirm actions (#1763)
Wire ConfirmModal and BusyButton so async confirms lock immediately, show spinner and progressive labels after duration-base, and block dismiss mid-flight. Connect stack delete and take-down to the existing stackAction map so the dialog is not idle until the toast.
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { DURATION_BASE_MS, useVisualBusy } from '../useVisualBusy';
|
||||
|
||||
describe('useVisualBusy', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not show busy while not pending', () => {
|
||||
const { result } = renderHook(() => useVisualBusy(false));
|
||||
expect(result.current.locked).toBe(false);
|
||||
expect(result.current.showBusy).toBe(false);
|
||||
});
|
||||
|
||||
it('never shows busy if pending ends before the delay', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ pending }) => useVisualBusy(pending),
|
||||
{ initialProps: { pending: true } },
|
||||
);
|
||||
expect(result.current.locked).toBe(true);
|
||||
expect(result.current.showBusy).toBe(false);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(DURATION_BASE_MS - 1);
|
||||
});
|
||||
expect(result.current.showBusy).toBe(false);
|
||||
|
||||
rerender({ pending: false });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(DURATION_BASE_MS);
|
||||
});
|
||||
expect(result.current.locked).toBe(false);
|
||||
expect(result.current.showBusy).toBe(false);
|
||||
});
|
||||
|
||||
it('shows busy after continuous pending through the delay', () => {
|
||||
const { result } = renderHook(() => useVisualBusy(true));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(DURATION_BASE_MS);
|
||||
});
|
||||
expect(result.current.locked).toBe(true);
|
||||
expect(result.current.showBusy).toBe(true);
|
||||
});
|
||||
|
||||
it('clears showBusy when pending becomes false', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ pending }) => useVisualBusy(pending),
|
||||
{ initialProps: { pending: true } },
|
||||
);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(DURATION_BASE_MS);
|
||||
});
|
||||
expect(result.current.showBusy).toBe(true);
|
||||
|
||||
rerender({ pending: false });
|
||||
expect(result.current.showBusy).toBe(false);
|
||||
});
|
||||
|
||||
it('does not update state after unmount mid-delay', () => {
|
||||
const { unmount } = renderHook(() => useVisualBusy(true));
|
||||
unmount();
|
||||
expect(() => {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(DURATION_BASE_MS);
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('restarts the delay on rapid pending toggles', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ pending }) => useVisualBusy(pending),
|
||||
{ initialProps: { pending: true } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(DURATION_BASE_MS - 50);
|
||||
});
|
||||
expect(result.current.showBusy).toBe(false);
|
||||
|
||||
rerender({ pending: false });
|
||||
rerender({ pending: true });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(DURATION_BASE_MS - 50);
|
||||
});
|
||||
expect(result.current.showBusy).toBe(false);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(50);
|
||||
});
|
||||
expect(result.current.showBusy).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Keep in sync with `--duration-base` in `frontend/src/index.css`.
|
||||
* Default gate before showing delayed busy chrome (spinner / progressive label).
|
||||
*/
|
||||
export const DURATION_BASE_MS = 220;
|
||||
|
||||
/**
|
||||
* Split interaction lock from delayed visual busy so fast ops never flash chrome.
|
||||
*
|
||||
* - `locked` tracks the raw `pending` flag (caller uses this to disable controls).
|
||||
* - `showBusy` becomes true only after `pending` stays true for `delayMs`.
|
||||
*/
|
||||
export function useVisualBusy(
|
||||
pending: boolean,
|
||||
delayMs: number = DURATION_BASE_MS,
|
||||
): { locked: boolean; showBusy: boolean } {
|
||||
const [showBusy, setShowBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pending) {
|
||||
setShowBusy(false);
|
||||
return;
|
||||
}
|
||||
setShowBusy(false);
|
||||
const id = window.setTimeout(() => {
|
||||
setShowBusy(true);
|
||||
}, delayMs);
|
||||
return () => {
|
||||
window.clearTimeout(id);
|
||||
};
|
||||
}, [pending, delayMs]);
|
||||
|
||||
return { locked: pending, showBusy };
|
||||
}
|
||||
Reference in New Issue
Block a user