feat(lists): switch lists from the title in the phone header
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bd58b37156
commit
161c33c3d7
5 files changed
+165
-6
No files matched your search
@@ -0,0 +1,87 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
import { CountBadge } from './CountBadge';
|
||||
|
||||
export interface ListSwitcherItem {
|
||||
id: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
interface ListSwitcherProps {
|
||||
// Shown on the button: the list that is open now
|
||||
label: string;
|
||||
items: ListSwitcherItem[];
|
||||
activeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
// Title of the open list in the phone header (Doku/ui-patterns.md): a tap opens all lists to switch to
|
||||
export const ListSwitcher: React.FC<ListSwitcherProps> = ({ label, items, activeId, onSelect }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e: Event) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
document.addEventListener('pointerdown', close);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', close);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
title="Liste wechseln"
|
||||
className="h-11 -my-1 max-w-full flex items-center gap-1 text-sm font-semibold text-main cursor-pointer"
|
||||
>
|
||||
<span className="truncate">{label}</span>
|
||||
<ChevronDown className={`w-4 h-4 text-muted flex-shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Liste wechseln"
|
||||
className="absolute left-0 top-full mt-1 z-40 w-72 max-w-[calc(100vw-2rem)] max-h-[60vh] overflow-y-auto py-1 bg-panel border border-border rounded-card shadow-panel"
|
||||
>
|
||||
{items.map((item) => {
|
||||
const active = item.id === activeId;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
if (!active) onSelect(item.id);
|
||||
}}
|
||||
className={`w-full h-11 px-3 flex items-center gap-2 text-left text-sm cursor-pointer hover:bg-panel-elevated ${
|
||||
active ? 'text-main font-semibold' : 'text-muted'
|
||||
}`}
|
||||
>
|
||||
{item.icon && <span className="flex-shrink-0">{item.icon}</span>}
|
||||
<span className="truncate flex-1">{item.title}</span>
|
||||
<CountBadge count={item.count} />
|
||||
{active && <Check className="w-4 h-4 text-accent flex-shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ListSwitcher } from '../ListSwitcher';
|
||||
|
||||
const items = [
|
||||
{ id: 'a', title: 'Haushalt', icon: '🏠', count: 3 },
|
||||
{ id: 'b', title: 'Arbeit' },
|
||||
];
|
||||
|
||||
describe('ListSwitcher', () => {
|
||||
it('opens all lists on a tap and switches to the chosen one', () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<ListSwitcher label="🏠 Haushalt" items={items} activeId="a" onSelect={onSelect} />);
|
||||
expect(screen.queryByRole('listbox')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Haushalt/ }));
|
||||
const options = screen.getAllByRole('option');
|
||||
expect(options).toHaveLength(2);
|
||||
expect(options[0].getAttribute('aria-selected')).toBe('true');
|
||||
|
||||
fireEvent.click(screen.getByRole('option', { name: /Arbeit/ }));
|
||||
expect(onSelect).toHaveBeenCalledWith('b');
|
||||
expect(screen.queryByRole('listbox')).toBeNull();
|
||||
});
|
||||
|
||||
it('closes without a change on the open list, Escape or a tap outside', () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<ListSwitcher label="🏠 Haushalt" items={items} activeId="a" onSelect={onSelect} />);
|
||||
const button = screen.getByRole('button', { name: /Haushalt/ });
|
||||
|
||||
fireEvent.click(button);
|
||||
fireEvent.click(screen.getByRole('option', { name: /Haushalt/ }));
|
||||
expect(screen.queryByRole('listbox')).toBeNull();
|
||||
|
||||
fireEvent.click(button);
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(screen.queryByRole('listbox')).toBeNull();
|
||||
|
||||
fireEvent.click(button);
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(screen.queryByRole('listbox')).toBeNull();
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user