From 161c33c3d77313672fe530f4a85e0c9a0c825095 Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 2 Nov 2026 10:55:02 +0100 Subject: [PATCH] feat(lists): switch lists from the title in the phone header Co-Authored-By: Claude Opus 5.5 --- Doku/ui-patterns.md | 8 ++ frontend/src/components/ListSwitcher.tsx | 87 +++++++++++++++++++ .../__tests__/ListSwitcher.test.tsx | 44 ++++++++++ .../src/features/lists/SharedListsView.tsx | 19 +++- frontend/src/features/triage/TasksView.tsx | 13 ++- 5 files changed, 165 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/ListSwitcher.tsx create mode 100644 frontend/src/components/__tests__/ListSwitcher.test.tsx diff --git a/Doku/ui-patterns.md b/Doku/ui-patterns.md index 9c81430..956cc36 100644 --- a/Doku/ui-patterns.md +++ b/Doku/ui-patterns.md @@ -77,6 +77,14 @@ Komponente: [OriginBadge.tsx](../frontend/src/components/OriginBadge.tsx) - Höchstens eine Badge pro Eintrag: der Schlüssel, der angelegt hat, sonst der, der zuletzt geändert hat. Sie bleibt dauerhaft, auch nach eigenem Bearbeiten und nach der Triage. - Details (angelegt / zuletzt geändert, mit Schlüssel und Zeit) per Mouseover und per Tippen (kleines Info-Fenster, am Handy mit vergrößerter Trefferfläche); in der Aufgaben-Detailansicht stehen sie zusätzlich unten (`originDetails`). Ein Tipp auf die Badge öffnet nie den Eintrag. - Größen: `sm` in Aufgabenzeilen, Inbox-Liste und Detailansicht, `xs` auf Kanban-Karten und in Listen, `md` auf den Triage-Karten. +- In der Aufgaben-Detailansicht steht die Badge am Handy nicht im Kopf (kein Platz neben den Knöpfen); dort zeigen nur die Details unten die Herkunft. + +## Liste wechseln (Handy) + +Komponente: [ListSwitcher.tsx](../frontend/src/components/ListSwitcher.tsx) + +- In der Kopfleiste am Handy (Aufgaben, Listen) ist der Titel der offenen Liste ein Knopf mit Pfeil. Ein Tipp klappt alle Listen auf, mit Icon, Zähler (`CountBadge`, `muted`) und Haken an der offenen; ein Tipp auf eine Liste wechselt dorthin. +- Schließt bei Tipp daneben und mit Escape. Am Desktop bleiben die Reiter über der Liste. ## Mikrofon in Eingabezeilen diff --git a/frontend/src/components/ListSwitcher.tsx b/frontend/src/components/ListSwitcher.tsx new file mode 100644 index 0000000..1f3847e --- /dev/null +++ b/frontend/src/components/ListSwitcher.tsx @@ -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 = ({ label, items, activeId, onSelect }) => { + const [open, setOpen] = useState(false); + const ref = useRef(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 ( +
+ + {open && ( +
+ {items.map((item) => { + const active = item.id === activeId; + return ( + + ); + })} +
+ )} +
+ ); +}; diff --git a/frontend/src/components/__tests__/ListSwitcher.test.tsx b/frontend/src/components/__tests__/ListSwitcher.test.tsx new file mode 100644 index 0000000..95b6646 --- /dev/null +++ b/frontend/src/components/__tests__/ListSwitcher.test.tsx @@ -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(); + 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(); + 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(); + }); +}); diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index 0fec8c6..4db8067 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -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 = ({ onRefreshGloba {/* Same height with or without the 36 px share button (Tasks, Shopping lists) */}
- - {activeList ? [activeList.icon, activeList.title].filter(Boolean).join(' ') : '🛒 Einkaufsliste'} - + {activeList ? ( + ({ + id: lst.id, + title: lst.title, + icon: lst.icon, + count: deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length, + }))} + activeId={activeList.id} + onSelect={setActiveListId} + /> + ) : ( + 🛒 Einkaufsliste + )} {openItems.length > 0 && ( {openItems.length} offen diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 6b70182..973001f 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -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 = ({ {/* Same height with or without the 36 px share button (Tasks, Shopping lists) */}
- - {currentSelectedList ? [currentSelectedList.icon, currentSelectedList.title].filter(Boolean).join(' ') : '📋 Alle Aufgaben'} - + ({ id: tl.id, title: tl.title, icon: tl.icon, count: tl.tasksCount })), + ]} + activeId={selectedListId} + onSelect={setSelectedListId} + /> {viewMode === 'kanban' ? 'Kanban'