feat: add UI/UX polish with navigation rail, confirm dialogs, welcome banner, and form validation

- Add NavigationRail component (desktop vertical icon sidebar + mobile bottom tab bar)
- Add ConfirmDialog with promise-based useConfirmDialog hook for async confirmation flow
- Add WelcomeBanner onboarding component (one-time display, localStorage persistence)
- Polish login form UX (shake on error, TOTP slide animation, password visibility toggle, session expired banner)
- Add inline form validation with shake animation in email composer and contacts
- Add empty state patterns for contacts (no data vs no search results with contextual actions)
- Improve toast notification system with undo action support and typed durations
- Add WCAG AA prefers-reduced-motion media query, safe area insets, sr-only live regions
- Add template settings tab and keyboard shortcut integration
- Update all 8 locale translations
This commit is contained in:
Matthieu MALVACHE
2026-02-17 02:31:50 +01:00
committed by Matthieu MALVACHE
parent 2636a88820
commit a43096485b
44 changed files with 2148 additions and 669 deletions
@@ -1,8 +1,16 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ContactDetail } from '../contact-detail';
import type { ContactCard } from '@/lib/jmap/types';
beforeEach(() => {
Object.assign(navigator, {
clipboard: {
writeText: vi.fn().mockResolvedValue(undefined),
},
});
});
const contact: ContactCard = {
id: '1',
addressBookIds: {},
@@ -52,10 +60,10 @@ describe('ContactDetail', () => {
it('calls onDelete when delete button is clicked', () => {
const onDelete = vi.fn();
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={onDelete} />);
const trashButtons = screen.getAllByRole('button').filter(
btn => btn.querySelector('svg') && btn.textContent?.trim() === ''
const deleteButton = screen.getAllByRole('button').find(
btn => btn.className.includes('text-red')
);
fireEvent.click(trashButtons[trashButtons.length - 1]);
fireEvent.click(deleteButton!);
expect(onDelete).toHaveBeenCalledOnce();
});
});
@@ -62,7 +62,7 @@ describe('ContactList', () => {
it('shows empty state when no contacts match', () => {
render(<ContactList {...defaultProps} contacts={[]} />);
expect(screen.getByText('empty_state')).toBeInTheDocument();
expect(screen.getByText('empty_state_title')).toBeInTheDocument();
});
it('shows search empty state when search has no results', () => {