feat(planner): add UndoBar component with 4s auto-dismiss
Shows undo notification after slot add/replace. Rückgängig button calls onundo, auto-dismisses after 4s via ondismiss callback. Also patches test-setup for userEvent + fake timers compatibility. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
67
frontend/src/lib/planner/UndoBar.svelte
Normal file
67
frontend/src/lib/planner/UndoBar.svelte
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
visible,
|
||||||
|
message,
|
||||||
|
onundo,
|
||||||
|
ondismiss
|
||||||
|
}: {
|
||||||
|
visible: boolean;
|
||||||
|
message: string;
|
||||||
|
onundo: () => void;
|
||||||
|
ondismiss: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
ondismiss();
|
||||||
|
}, 4000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if visible}
|
||||||
|
<div
|
||||||
|
data-testid="undo-bar"
|
||||||
|
role="status"
|
||||||
|
style="
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--color-text);
|
||||||
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span style="color: #E8E8E2; font-family: var(--font-sans); font-size: 14px;">
|
||||||
|
{message}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onundo}
|
||||||
|
style="
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--green-dark);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 0;
|
||||||
|
text-decoration: none;
|
||||||
|
"
|
||||||
|
onmouseenter={(e) => ((e.currentTarget as HTMLButtonElement).style.textDecoration = 'underline')}
|
||||||
|
onmouseleave={(e) => ((e.currentTarget as HTMLButtonElement).style.textDecoration = 'none')}
|
||||||
|
>
|
||||||
|
Rückgängig
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
56
frontend/src/lib/planner/UndoBar.test.ts
Normal file
56
frontend/src/lib/planner/UndoBar.test.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen, act } from '@testing-library/svelte';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import UndoBar from './UndoBar.svelte';
|
||||||
|
|
||||||
|
describe('UndoBar', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not mounted when visible is false', () => {
|
||||||
|
render(UndoBar, { props: { visible: false, message: 'Test', onundo: vi.fn(), ondismiss: vi.fn() } });
|
||||||
|
expect(screen.queryByTestId('undo-bar')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is mounted and shows message when visible is true', () => {
|
||||||
|
render(UndoBar, { props: { visible: true, message: 'Gericht hinzugefügt', onundo: vi.fn(), ondismiss: vi.fn() } });
|
||||||
|
expect(screen.getByTestId('undo-bar')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Gericht hinzugefügt')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Rückgängig button', () => {
|
||||||
|
render(UndoBar, { props: { visible: true, message: 'Test', onundo: vi.fn(), ondismiss: vi.fn() } });
|
||||||
|
expect(screen.getByRole('button', { name: /Rückgängig/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onundo when Rückgängig is clicked', async () => {
|
||||||
|
const onundo = vi.fn();
|
||||||
|
render(UndoBar, { props: { visible: true, message: 'Test', onundo, ondismiss: vi.fn() } });
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Rückgängig/i }));
|
||||||
|
expect(onundo).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls ondismiss after 4 seconds', async () => {
|
||||||
|
const ondismiss = vi.fn();
|
||||||
|
render(UndoBar, { props: { visible: true, message: 'Test', onundo: vi.fn(), ondismiss } });
|
||||||
|
await act(() => { vi.advanceTimersByTime(4000); });
|
||||||
|
expect(ondismiss).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call ondismiss before 4 seconds', async () => {
|
||||||
|
const ondismiss = vi.fn();
|
||||||
|
render(UndoBar, { props: { visible: true, message: 'Test', onundo: vi.fn(), ondismiss } });
|
||||||
|
await act(() => { vi.advanceTimersByTime(3999); });
|
||||||
|
expect(ondismiss).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has role="status" for accessibility', () => {
|
||||||
|
render(UndoBar, { props: { visible: true, message: 'Test', onundo: vi.fn(), ondismiss: vi.fn() } });
|
||||||
|
expect(screen.getByRole('status')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1 +1,35 @@
|
|||||||
import '@testing-library/jest-dom/vitest';
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
// Import @testing-library/svelte here so its beforeEach (setup/asyncWrapper=act)
|
||||||
|
// is registered before our own beforeEach below.
|
||||||
|
import '@testing-library/svelte';
|
||||||
|
import { configure } from '@testing-library/dom';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
// Patch userEvent direct-API methods to use delay:null when fake timers are
|
||||||
|
// active. With delay:null, user-event's internal wait() short-circuits
|
||||||
|
// (typeof null !== 'number') and no setTimeout is scheduled — so clicks and
|
||||||
|
// other interactions work correctly under vi.useFakeTimers().
|
||||||
|
const originalClick = userEvent.click.bind(userEvent);
|
||||||
|
// @ts-expect-error patching direct API
|
||||||
|
userEvent.click = (element: Element, options = {}) => {
|
||||||
|
if (vi.isFakeTimers()) {
|
||||||
|
// @ts-expect-error delay:null is a valid user-event option
|
||||||
|
return originalClick(element, { delay: null, ...options });
|
||||||
|
}
|
||||||
|
return originalClick(element, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Also update asyncWrapper to call tick() after async operations so Svelte
|
||||||
|
// DOM updates are flushed. @testing-library/svelte's act() already does this,
|
||||||
|
// but we re-configure after it to preserve our fake-timer behaviour.
|
||||||
|
beforeEach(() => {
|
||||||
|
configure({
|
||||||
|
asyncWrapper: async (fn: () => Promise<unknown>) => {
|
||||||
|
const result = await fn();
|
||||||
|
await tick();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user