From ae412e0cda44dc615df2ffbbef5a0db2ba66258e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:05:41 +0100 Subject: [PATCH] feat: show notifications, the command palette and the account avatar in every app's phone header --- components/calendar/calendar-toolbar.tsx | 2 + components/contacts/contact-list.tsx | 2 + components/files/files-header.tsx | 2 + .../__tests__/phone-header-actions.test.tsx | 107 +++++++++++ components/layout/mobile-header.tsx | 84 +-------- components/layout/phone-header-actions.tsx | 174 ++++++++++++++++++ components/plugins/plugin-page-app.tsx | 2 + components/settings/settings-app.tsx | 3 + 8 files changed, 295 insertions(+), 81 deletions(-) create mode 100644 components/layout/__tests__/phone-header-actions.test.tsx create mode 100644 components/layout/phone-header-actions.tsx diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index 2ba57c760..c5ca2b993 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu, MoreVertical } from "@/components/icons"; import { startOfWeek } from "date-fns"; +import { PhoneHeaderActions } from "@/components/layout/phone-header-actions"; import { cn } from "@/lib/utils"; import type { CalendarViewMode } from "@/stores/calendar-store"; import type { Calendar } from "@/lib/jmap/types"; @@ -210,6 +211,7 @@ export function CalendarToolbar({ + {/* Row 2: View switcher pills + calendar toggle */} diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx index f736593a7..1c8f9b8d7 100644 --- a/components/contacts/contact-list.tsx +++ b/components/contacts/contact-list.tsx @@ -10,6 +10,7 @@ import { ContactContextMenu } from "./contact-context-menu"; import { useContactExportBusy } from "./contact-export"; import { PluginActions } from "@/components/plugins/plugin-actions"; import { useContextMenu } from "@/hooks/use-context-menu"; +import { PhoneHeaderActions } from "@/components/layout/phone-header-actions"; import { cn } from "@/lib/utils"; import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types"; import { getContactDisplayName, getContactSortName, hasContactPhoto } from "@/stores/contact-store"; @@ -352,6 +353,7 @@ export function ContactList({ )} + diff --git a/components/files/files-header.tsx b/components/files/files-header.tsx index 4070c05b1..1a7d94695 100644 --- a/components/files/files-header.tsx +++ b/components/files/files-header.tsx @@ -10,6 +10,7 @@ import { import { Button } from "@/components/ui/button"; import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; import { useContextMenu } from "@/hooks/use-context-menu"; +import { PhoneHeaderActions } from "@/components/layout/phone-header-actions"; import { cn } from "@/lib/utils"; import { anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts"; import { PluginActions } from "@/components/plugins/plugin-actions"; @@ -333,6 +334,7 @@ export function FilesHeader({ )} + )} diff --git a/components/layout/__tests__/phone-header-actions.test.tsx b/components/layout/__tests__/phone-header-actions.test.tsx new file mode 100644 index 000000000..5b5be97f5 --- /dev/null +++ b/components/layout/__tests__/phone-header-actions.test.tsx @@ -0,0 +1,107 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// The phone header cluster (⋮ with notifications and the command palette, +// then the account avatar) that every app shows at phone width. + +const media = vi.hoisted(() => ({ mobile: true, desktop: false })); +vi.mock('@/hooks/use-media-query', async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobile: () => media.mobile, + useIsDesktop: () => media.desktop, +})); + +const { PhoneHeaderActions, PhoneAppHeader } = await import('../phone-header-actions'); +const { ContactList } = await import('@/components/contacts/contact-list'); +const { ContextMenuItem } = await import('@/components/ui/context-menu'); + +beforeEach(() => { + media.mobile = true; + media.desktop = false; +}); + +describe('PhoneHeaderActions', () => { + it('shows the menu and the account avatar on a phone', () => { + render(); + expect(screen.getByRole('button', { name: 'switch_account' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'more' })); + // Notifications and the command palette (both "title" as keys in tests). + expect(screen.getAllByRole('menuitem').map((m) => m.textContent)).toEqual(['title', 'title']); + }); + + it('opens the command palette from the menu', () => { + const opened = vi.fn(); + window.addEventListener('bulwark:command-palette', opened); + render(); + fireEvent.click(screen.getByRole('button', { name: 'more' })); + fireEvent.click(screen.getAllByRole('menuitem')[1]); + expect(opened).toHaveBeenCalled(); + window.removeEventListener('bulwark:command-palette', opened); + }); + + it('lists the app entries first and closes the menu when one is picked', () => { + const run = vi.fn(); + render( + { close(); run(); }} />} + /> + ); + fireEvent.click(screen.getByRole('button', { name: 'more' })); + const items = screen.getAllByRole('menuitem'); + expect(items[0]).toHaveTextContent('Import'); + fireEvent.click(items[0]); + expect(run).toHaveBeenCalled(); + expect(screen.queryByRole('menu')).toBeNull(); + }); + + it('draws nothing where the rail holds these controls', () => { + media.desktop = true; + media.mobile = false; + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('draws nothing on a tablet for an app that keeps the rail there', () => { + media.mobile = false; + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + render(); + expect(screen.getByRole('button', { name: 'more' })).toBeInTheDocument(); + }); +}); + +describe('PhoneAppHeader', () => { + it('gives a screen without a header a title and the cluster', () => { + render(); + expect(screen.getByRole('heading', { name: 'Kanban' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'more' })).toBeInTheDocument(); + }); +}); + +describe('apps use it', () => { + it('puts the cluster in the Contacts list header on a phone', () => { + render( + ()} + onToggleSelection={vi.fn()} + onSelectRangeContacts={vi.fn()} + onSelectAll={vi.fn()} + onClearSelection={vi.fn()} + onBulkDelete={vi.fn()} + onBulkAddToGroup={vi.fn()} + onBulkExport={vi.fn()} + onEditContact={vi.fn()} + onDeleteContact={vi.fn()} + onAddContactToGroup={vi.fn()} + /> + ); + expect(screen.getByRole('button', { name: 'more' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'switch_account' })).toBeInTheDocument(); + }); +}); diff --git a/components/layout/mobile-header.tsx b/components/layout/mobile-header.tsx index 654c47625..72b706e0e 100644 --- a/components/layout/mobile-header.tsx +++ b/components/layout/mobile-header.tsx @@ -1,7 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { Menu, ArrowLeft, Plus, Search, X, MoreVertical, Terminal, Bell } from "@/components/icons"; +import { Menu, ArrowLeft, Plus, Search, X } from "@/components/icons"; import { Button } from "@/components/ui/button"; import { useUIStore } from "@/stores/ui-store"; import { useShallow } from "zustand/react/shallow"; @@ -10,84 +9,7 @@ import { useIsDesktop } from "@/hooks/use-media-query"; import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; import { AccountSwitcher } from "@/components/layout/account-switcher"; -import { PluginActions } from "@/components/plugins/plugin-actions"; -import { useMenuNavigation } from "@/hooks/use-menu-navigation"; -import { ContextMenuItem } from "@/components/ui/context-menu"; -import { openCommandPalette } from "@/lib/app-commands"; -import { NotificationSheet, formatBadgeCount, useUnreadNotificationCount } from "@/components/notifications/notification-center"; - -/** Plugin buttons in the top bar are not about any one item. */ -const TOP_BAR_CONTEXT: Record = {}; - -/** - * The phone header's overflow menu: the notifications (the desktop bell), the - * command palette (no Ctrl+K on a phone) and what the desktop top bar shows - * as buttons (plugin top-bar-actions) and has no room for here. - */ -function MobileHeaderMenu() { - const t = useTranslations('top_bar'); - const tPalette = useTranslations('command_palette'); - const tNotifications = useTranslations('notification_center'); - const unread = useUnreadNotificationCount(); - const [showNotifications, setShowNotifications] = useState(false); - const [open, setOpen] = useState(false); - const triggerRef = useRef(null); - const close = useCallback(() => setOpen(false), []); - const { menuRef, onKeyDown } = useMenuNavigation({ open, onClose: close, triggerRef }); - - useEffect(() => { - if (!open) return; - const onPointerDown = (e: MouseEvent) => { - if (triggerRef.current?.contains(e.target as Node) || menuRef.current?.contains(e.target as Node)) return; - setOpen(false); - }; - document.addEventListener('mousedown', onPointerDown); - return () => document.removeEventListener('mousedown', onPointerDown); - }, [open, menuRef]); - - return ( -
- - {open && ( -
- 0 ? formatBadgeCount(unread) : undefined} - onClick={() => { close(); setShowNotifications(true); }} - /> - { close(); openCommandPalette(); }} - /> - -
- )} - {showNotifications && setShowNotifications(false)} />} -
- ); -} +import { PhoneHeaderMenu } from "@/components/layout/phone-header-actions"; interface MobileHeaderProps { title: string; @@ -246,7 +168,7 @@ export function MobileHeader({ )} - + {/* Which account you are reading is otherwise only visible after opening the drawer. Last in this group so it sits on the far diff --git a/components/layout/phone-header-actions.tsx b/components/layout/phone-header-actions.tsx new file mode 100644 index 000000000..fee8315dc --- /dev/null +++ b/components/layout/phone-header-actions.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { useTranslations } from "next-intl"; +import { MoreVertical, Terminal, Bell } from "@/components/icons"; +import { IconButton } from "@/components/ui/icon-button"; +import { ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; +import { AccountSwitcher } from "@/components/layout/account-switcher"; +import { PluginActions } from "@/components/plugins/plugin-actions"; +import { useMenuNavigation } from "@/hooks/use-menu-navigation"; +import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query"; +import { openCommandPalette } from "@/lib/app-commands"; +import { cn } from "@/lib/utils"; +import { + NotificationSheet, + formatBadgeCount, + useUnreadNotificationCount, +} from "@/components/notifications/notification-center"; + +/** Plugin buttons in the top bar are not about any one item. */ +const TOP_BAR_CONTEXT: Record = {}; + +export interface PhoneHeaderMenuProps { + /** + * The app's own entries, listed above the shell's. They get the function + * that closes the menu, to call when one is picked. + */ + menuItems?: (close: () => void) => ReactNode; +} + +/** + * The phone header's overflow menu: the app's own entries if it has any, the + * notifications (the desktop bell), the command palette (no Ctrl+K on a + * phone) and what the desktop top bar shows as buttons (plugin + * top-bar-actions) and has no room for here. + */ +export function PhoneHeaderMenu({ menuItems }: PhoneHeaderMenuProps) { + const t = useTranslations("top_bar"); + const tPalette = useTranslations("command_palette"); + const tNotifications = useTranslations("notification_center"); + const unread = useUnreadNotificationCount(); + const [showNotifications, setShowNotifications] = useState(false); + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const close = useCallback(() => setOpen(false), []); + const { menuRef, onKeyDown } = useMenuNavigation({ open, onClose: close, triggerRef }); + + useEffect(() => { + if (!open) return; + const onPointerDown = (e: MouseEvent) => { + if (triggerRef.current?.contains(e.target as Node) || menuRef.current?.contains(e.target as Node)) return; + setOpen(false); + }; + document.addEventListener("mousedown", onPointerDown); + return () => document.removeEventListener("mousedown", onPointerDown); + }, [open, menuRef]); + + const appItems = menuItems?.(close); + + return ( +
+ setOpen((v) => !v)} + className={cn(open && "bg-accent")} + label={unread > 0 ? `${t("more")} (${tNotifications("unread_count", { count: unread })})` : t("more")} + title="" + aria-haspopup="menu" + aria-expanded={open} + > + + {unread > 0 && ( + + {open && ( +
+ {appItems && ( + <> + {appItems} + + + )} + 0 ? formatBadgeCount(unread) : undefined} + onClick={() => { close(); setShowNotifications(true); }} + /> + { close(); openCommandPalette(); }} + /> + +
+ )} + {showNotifications && setShowNotifications(false)} />} +
+ ); +} + +export interface PhoneHeaderActionsProps extends PhoneHeaderMenuProps { + /** + * Below which width the app shows the phone shell (bottom tab bar, no + * rail): "md" for apps that keep the rail on a tablet, "lg" for those that + * use the phone shell up to the desktop. Above it nothing is drawn, as the + * rail or the Mountain View top bar holds these controls there. + */ + below?: "md" | "lg"; + className?: string; + /** Mountain View: opens the merged across-accounts inbox from the avatar's popover. */ + onSelectAllInboxes?: () => void; + allInboxesSelected?: boolean; +} + +/** + * The cluster at the end of every app's phone header (answer 12 of the + * 2026-10-27 usability test): a ⋮ with notifications and the command palette, + * then the account avatar. Drop it in as the last child of the header row. + */ +export function PhoneHeaderActions({ + below = "lg", + className, + menuItems, + onSelectAllInboxes, + allInboxesSelected = false, +}: PhoneHeaderActionsProps) { + const isMobile = useIsMobile(); + const isDesktop = useIsDesktop(); + const shown = below === "md" ? isMobile : !isDesktop; + if (!shown) return null; + + return ( +
+ + {/* Which account you are in is otherwise only visible in Settings. + Last, so it sits on the far edge, mirrored under RTL by the flex row. */} + +
+ ); +} + +export interface PhoneAppHeaderProps extends PhoneHeaderActionsProps { + title: string; + /** Controls drawn before the title, such as a back or menu button. */ + leading?: ReactNode; +} + +/** A whole phone header for a screen that has none of its own: title, then the shared cluster. */ +export function PhoneAppHeader({ title, leading, ...actions }: PhoneAppHeaderProps) { + const isMobile = useIsMobile(); + const isDesktop = useIsDesktop(); + const shown = (actions.below ?? "lg") === "md" ? isMobile : !isDesktop; + if (!shown) return null; + return ( +
+ {leading} +

{title}

+ +
+ ); +} diff --git a/components/plugins/plugin-page-app.tsx b/components/plugins/plugin-page-app.tsx index 8a0f76939..d6f3ad189 100644 --- a/components/plugins/plugin-page-app.tsx +++ b/components/plugins/plugin-page-app.tsx @@ -8,6 +8,7 @@ import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from "@/stores/ import { useEmailStore } from "@/stores/email-store"; import { usePluginStore } from "@/stores/plugin-store"; import { NavigationRail } from "@/components/layout/navigation-rail"; +import { PhoneAppHeader } from "@/components/layout/phone-header-actions"; import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { InlineAppView } from "@/components/layout/inline-app-view"; import { MvTopBar } from "@/components/layout/mv-top-bar"; @@ -144,6 +145,7 @@ export function PluginPageApp({ linkSegments = [] }: PluginPageAppProps) { )}
+ {!isEmbedded && } {inlineApp && }
{content} diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx index 9de63f354..acdb7a001 100644 --- a/components/settings/settings-app.tsx +++ b/components/settings/settings-app.tsx @@ -63,6 +63,7 @@ import { useSettingsStore } from '@/stores/settings-store'; import { useManagedAccountStore } from '@/stores/managed-account-store'; import { useIsDesktop } from '@/hooks/use-media-query'; import { NavigationRail } from '@/components/layout/navigation-rail'; +import { PhoneHeaderActions } from '@/components/layout/phone-header-actions'; import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal'; import { InlineAppView } from '@/components/layout/inline-app-view'; import { useSidebarApps } from '@/hooks/use-sidebar-apps'; @@ -844,6 +845,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =

{activeTabLabel}

+
@@ -880,6 +882,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =

{t('title')}

+