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:
sandrodachandClaude Opus 5.5 committed 2026-11-02 10:55:02 +01:00
1 parent bd58b37156
commit 161c33c3d7
5 files changed
+165 -6

No files matched your search

+87
View File
@@ -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();
});
});
@@ -16,6 +16,7 @@ import { OFFLINE_ITEM_PREFIX, pendingListItems } from '../../lib/offline';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
import { CountBadge } from '../../components/CountBadge';
import { ListSwitcher } from '../../components/ListSwitcher';
import { OriginBadge } from '../../components/OriginBadge';
import { VoiceInputButton } from '../../components/VoiceInputButton';
import { FormSheet } from '../../components/FormSheet';
@@ -1170,9 +1171,21 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
{/* Same height with or without the 36 px share button (Tasks, Shopping lists) */}
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 min-h-[54px] rounded-btn bg-panel border border-border !mt-0">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-semibold text-main truncate">
{activeList ? [activeList.icon, activeList.title].filter(Boolean).join(' ') : '🛒 Einkaufsliste'}
</span>
{activeList ? (
<ListSwitcher
label={[activeList.icon, activeList.title].filter(Boolean).join(' ')}
items={lists.map((lst) => ({
id: lst.id,
title: lst.title,
icon: lst.icon,
count: deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length,
}))}
activeId={activeList.id}
onSelect={setActiveListId}
/>
) : (
<span className="text-sm font-semibold text-main truncate">🛒 Einkaufsliste</span>
)}
{openItems.length > 0 && (
<span className="badge-neutral text-[11px] flex-shrink-0">
{openItems.length} offen
+10 -3
View File
@@ -40,6 +40,7 @@ import { VoiceInputButton } from '../../components/VoiceInputButton';
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS, columnDotClass } from './ManageColumnsModal';
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
import { CountBadge } from '../../components/CountBadge';
import { ListSwitcher } from '../../components/ListSwitcher';
import { FormSheet } from '../../components/FormSheet';
import { EmojiIconButton } from '../../components/EmojiIconButton';
import { useMobileActions } from '../../context/MobileActionContext';
@@ -1953,9 +1954,15 @@ export const TasksView: React.FC<TasksViewProps> = ({
{/* Same height with or without the 36 px share button (Tasks, Shopping lists) */}
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 min-h-[54px] rounded-btn bg-panel border border-border !mt-0">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-semibold text-main truncate">
{currentSelectedList ? [currentSelectedList.icon, currentSelectedList.title].filter(Boolean).join(' ') : '📋 Alle Aufgaben'}
</span>
<ListSwitcher
label={currentSelectedList ? [currentSelectedList.icon, currentSelectedList.title].filter(Boolean).join(' ') : '📋 Alle Aufgaben'}
items={[
{ id: 'all', title: 'Alle Aufgaben', icon: '📋', count: openTasksCount },
...safeTaskLists.map((tl) => ({ id: tl.id, title: tl.title, icon: tl.icon, count: tl.tasksCount })),
]}
activeId={selectedListId}
onSelect={setSelectedListId}
/>
<span className="badge-neutral text-[11px] flex-shrink-0">
{viewMode === 'kanban'
? 'Kanban'