diff --git a/Doku/ui-patterns.md b/Doku/ui-patterns.md index fe6a27b..0c2e1f5 100644 --- a/Doku/ui-patterns.md +++ b/Doku/ui-patterns.md @@ -41,8 +41,15 @@ Komponente: [ToggleSwitch.tsx](../frontend/src/components/ToggleSwitch.tsx) ## Zähler -CSS-Klasse `badge-count` in [index.css](../frontend/src/index.css): runder Zähler in der Form der Zähler der Navigationsleiste, aber **monochrom** (grau gefüllt) statt in der Akzentfarbe. +Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Klassen `count-badge*` in [index.css](../frontend/src/index.css). Es gibt keine anderen Zähler-Stile mehr. -- Verwendet für die Anzahl offener Einträge in den Tabs der Aufgabenlisten und der Listen. -- Die Zähler der Navigationsleiste bleiben blau (Akzent), weil sie auf neue Inhalte aufmerksam machen. -- Kein eckiges `badge-neutral` mehr für Zähler in Tabs. +- **Form:** runder Kreis, mindestens 16 px, ab 100 als „99+“ (der Screenreader liest die echte Zahl). Bei 0 ausgeblendet, außer mit `showZero` (z. B. Mitgliederzahl, mobiles Menü). +- **Farbe (`variant`):** + - `accent`: Hier wartet etwas auf dich. Navigationsleiste (Triage, Workspace, Aufgaben). + - `muted` (Standard): reine Anzahl. Tabs der Aufgabenlisten und Listen, Organisationen, mobiles Menü. + - Beide kommen aus den Theme-Variablen (`--accent-primary`/`--text-on-accent` bzw. `--text-muted`/`--bg-panel`) und passen sich damit jedem Theme an. +- **Position (`position`):** + - `corner`: Benachrichtigungs-Badge oben rechts am nächsten `relative` Elternelement, mit Ring in der Seitenfarbe. So in der Navigation und an den Tabs. Zeilen mit solchen Tabs brauchen etwas Abstand (`gap-x-3`, `pt-2`), damit der Kreis nicht in den Nachbar-Tab ragt. + - `inline`: im Textfluss. +- Zahlen im mobilen Menü (`MobileActionContext`, `badge: number`) werden automatisch als `CountBadge` gezeigt; Text-Badges wie „2 aktiv“ bleiben Textmarken. +- **Wichtig für neue Klassen:** Tailwind entfernt Klassen aus `@layer components`, die nicht wörtlich im Code stehen. Klassennamen deshalb nie zusammensetzen (`count-badge-${variant}`), sondern ausschreiben. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dc7b0b2..fc2a4f3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -43,6 +43,7 @@ import { ThemeId, } from './lib/theme'; import { InstantCaptureBar } from './features/capture/InstantCaptureBar'; +import { CountBadge } from './components/CountBadge'; import { TriageDeck } from './features/triage/TriageDeck'; import { TasksView } from './features/triage/TasksView'; import { WorkspaceView } from './features/workspace/WorkspaceView'; @@ -905,11 +906,7 @@ export const App: React.FC = () => { 0 ? 'pr-2' : ''}`}> Triage - {stats.inboxCount > 0 && ( - - {stats.inboxCount} - - )} + @@ -923,11 +920,7 @@ export const App: React.FC = () => { 0 ? 'pr-2' : ''}`}> Workspace - {(stats.docsTodosOpenCount ?? 0) > 0 && ( - - {stats.docsTodosOpenCount} - - )} + @@ -941,11 +934,7 @@ export const App: React.FC = () => { 0 ? 'pr-2' : ''}`}> Aufgaben - {stats.tasksOpenCount > 0 && ( - - {stats.tasksOpenCount} - - )} + @@ -1002,6 +991,7 @@ export const App: React.FC = () => { onOpenDocument={handleOpenDocument} refreshTrigger={refreshRevision} initialSelectedTaskId={selectedTaskId} + openTasksCount={stats.tasksOpenCount} /> )} @@ -1072,11 +1062,7 @@ export const App: React.FC = () => { >
- {stats.inboxCount > 0 && ( - - {stats.inboxCount} - - )} +
Triage @@ -1095,11 +1081,7 @@ export const App: React.FC = () => { >
- {(stats.docsTodosOpenCount ?? 0) > 0 && ( - - {stats.docsTodosOpenCount} - - )} +
Workspace @@ -1137,11 +1119,7 @@ export const App: React.FC = () => { >
- {stats.tasksOpenCount > 0 && ( - - {stats.tasksOpenCount} - - )} +
Aufgaben diff --git a/frontend/src/components/CountBadge.tsx b/frontend/src/components/CountBadge.tsx new file mode 100644 index 0000000..f512dde --- /dev/null +++ b/frontend/src/components/CountBadge.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +interface CountBadgeProps { + count: number | undefined | null; + // accent: something waits for you (nav bar); muted: plain amount (tabs, lists) + variant?: 'accent' | 'muted'; + // corner: notification badge on the top right of the nearest `relative` parent + position?: 'inline' | 'corner'; + showZero?: boolean; + className?: string; // e.g. to fine-tune the corner offset +} + +// Full class names: Tailwind drops component classes it cannot find literally in the source +const VARIANT_CLASS = { accent: 'count-badge-accent', muted: 'count-badge-muted' }; + +// The one counter of the app (Doku/ui-patterns.md); hides itself at 0 unless showZero +export const CountBadge: React.FC = ({ + count, + variant = 'muted', + position = 'inline', + showZero = false, + className = '', +}) => { + if (count === undefined || count === null || (count <= 0 && !showZero)) return null; + const label = count > 99 ? '99+' : String(count); + return ( + + {label} + + ); +}; diff --git a/frontend/src/components/MobileHeaderMenu.tsx b/frontend/src/components/MobileHeaderMenu.tsx index f2f3d87..b254d80 100644 --- a/frontend/src/components/MobileHeaderMenu.tsx +++ b/frontend/src/components/MobileHeaderMenu.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Check, X } from 'lucide-react'; import { useMobileActions } from '../context/MobileActionContext'; import { triggerHaptic } from '../lib/api'; +import { CountBadge } from './CountBadge'; interface MobileHeaderMenuProps { activeTabLabel: string; @@ -109,7 +110,9 @@ export const MobileHeaderMenu: React.FC = ({ activeTabLab
- {item.badge !== undefined && item.badge !== '' && ( + {typeof item.badge === 'number' && !isAccent ? ( + + ) : item.badge !== undefined && item.badge !== '' && ( { + it('hides itself at zero unless asked', () => { + expect(render().container.firstChild).toBeNull(); + expect(render().container.firstChild).toBeNull(); + expect(render().container.textContent).toBe('0'); + }); + + it('caps large numbers and keeps the real value for screen readers', () => { + const el = render().container.firstChild as HTMLElement; + expect(el.textContent).toBe('99+'); + expect(el.getAttribute('aria-label')).toBe('150'); + }); + + it('maps variant and position to the shared classes', () => { + const el = render().container.firstChild as HTMLElement; + expect(el.className).toContain('count-badge-accent'); + expect(el.className).toContain('count-badge-corner'); + }); +}); diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index 1a3dfc5..0e0a28f 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -14,6 +14,7 @@ import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../li import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { AvatarStack, AvatarPerson } from '../../components/AvatarStack'; +import { CountBadge } from '../../components/CountBadge'; import { useMobileActions } from '../../context/MobileActionContext'; interface SharedListsViewProps { @@ -842,8 +843,8 @@ export const SharedListsView: React.FC = ({ onRefreshGloba
{/* Top Header: List Tabs & Actions (Desktop) */}
- {/* List Tabs */} -
+ {/* List Tabs (counters sit on the top right corner, hence the extra gap) */} +
{lists.map((lst) => { const openCount = deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length; const isSelected = activeList?.id === lst.id; @@ -860,9 +861,6 @@ export const SharedListsView: React.FC = ({ onRefreshGloba > {lst.icon} {lst.title} - {openCount > 0 && ( - {openCount} - )} = ({ onRefreshGloba className="ml-0.5" /> + {isSelected && lists.length > 1 && ( {safeTaskLists.map((tl) => ( @@ -1394,9 +1400,7 @@ export const TasksView: React.FC = ({ > {tl.icon} {tl.title} - {tl.tasksCount !== undefined && tl.tasksCount > 0 && ( - {tl.tasksCount} - )} + {(() => { const isShared = (tl.shares && tl.shares.length > 0) || (tl.members && tl.members.length > 1); if (!isShared) return null; @@ -1418,6 +1422,7 @@ export const TasksView: React.FC = ({ ); })()} + {selectedListId === tl.id && (