feat: show notifications, the command palette and the account avatar in every app's phone header

This commit is contained in:
Linus Rath
2026-10-28 03:05:41 +01:00
parent 295c0d6c49
commit ae412e0cda
8 changed files with 295 additions and 81 deletions
+2
View File
@@ -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({
<Button variant="ghost" size="sm" onClick={onToday} className="touch-manipulation text-xs h-7 px-2 ms-0.5">
{t("views.today")}
</Button>
<PhoneHeaderActions below="md" />
</div>
{/* Row 2: View switcher pills + calendar toggle */}
+2
View File
@@ -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({
</span>
)}
</button>
<PhoneHeaderActions below="md" />
</div>
</div>
+2
View File
@@ -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({
</Button>
)}
<DetailsToggle showDetails={showDetails} onToggle={onToggleDetails} label={t("details")} />
<PhoneHeaderActions below="md" />
</div>
)}
@@ -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<typeof import('@/hooks/use-media-query')>()),
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(<PhoneHeaderActions />);
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(<PhoneHeaderActions />);
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(
<PhoneHeaderActions
menuItems={(close) => <ContextMenuItem label="Import" onClick={() => { 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(<PhoneHeaderActions />);
expect(container).toBeEmptyDOMElement();
});
it('draws nothing on a tablet for an app that keeps the rail there', () => {
media.mobile = false;
const { container } = render(<PhoneHeaderActions below="md" />);
expect(container).toBeEmptyDOMElement();
render(<PhoneHeaderActions />);
expect(screen.getByRole('button', { name: 'more' })).toBeInTheDocument();
});
});
describe('PhoneAppHeader', () => {
it('gives a screen without a header a title and the cluster', () => {
render(<PhoneAppHeader title="Kanban" below="md" />);
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(
<ContactList
contacts={[]}
selectedContactId={null}
searchQuery=""
onSearchChange={vi.fn()}
onSelectContact={vi.fn()}
onCreateNew={vi.fn()}
selectedContactIds={new Set<string>()}
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();
});
});
+3 -81
View File
@@ -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<string, unknown> = {};
/**
* 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<HTMLButtonElement>(null);
const close = useCallback(() => setOpen(false), []);
const { menuRef, onKeyDown } = useMenuNavigation<HTMLDivElement>({ 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 (
<div className="relative">
<Button
ref={triggerRef}
variant="ghost"
size="icon"
onClick={() => setOpen((v) => !v)}
className={cn("relative h-11 w-11", open && "bg-accent")}
aria-label={unread > 0 ? `${t('more')} (${tNotifications('unread_count', { count: unread })})` : t('more')}
aria-haspopup="menu"
aria-expanded={open}
>
<MoreVertical className="h-5 w-5" />
{unread > 0 && (
<span className="absolute top-2 end-2 w-2 h-2 rounded-full bg-red-500 ring-2 ring-background" aria-hidden="true" />
)}
</Button>
{open && (
<div
ref={menuRef}
onKeyDown={onKeyDown}
role="menu"
aria-label={t('more')}
className="absolute end-0 top-full mt-1 z-50 min-w-[200px] max-w-[calc(100vw-2rem)] rounded-md border border-border bg-background py-1 shadow-lg"
>
<ContextMenuItem
icon={Bell}
label={tNotifications('title')}
shortcut={unread > 0 ? formatBadgeCount(unread) : undefined}
onClick={() => { close(); setShowNotifications(true); }}
/>
<ContextMenuItem
icon={Terminal}
label={tPalette('title')}
onClick={() => { close(); openCommandPalette(); }}
/>
<PluginActions surface="top-bar-actions" context={TOP_BAR_CONTEXT} variant="menu" onRan={close} />
</div>
)}
{showNotifications && <NotificationSheet onClose={() => setShowNotifications(false)} />}
</div>
);
}
import { PhoneHeaderMenu } from "@/components/layout/phone-header-actions";
interface MobileHeaderProps {
title: string;
@@ -246,7 +168,7 @@ export function MobileHeader({
</Button>
)}
<MobileHeaderMenu />
<PhoneHeaderMenu />
{/* Which account you are reading is otherwise only visible after
opening the drawer. Last in this group so it sits on the far
+174
View File
@@ -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<string, unknown> = {};
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<HTMLButtonElement>(null);
const close = useCallback(() => setOpen(false), []);
const { menuRef, onKeyDown } = useMenuNavigation<HTMLDivElement>({ 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 (
<div className="relative">
<IconButton
ref={triggerRef}
size="icon-lg"
onClick={() => 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}
>
<MoreVertical />
{unread > 0 && (
<span className="absolute top-2 end-2 w-2 h-2 rounded-full bg-red-500 ring-2 ring-background" aria-hidden="true" />
)}
</IconButton>
{open && (
<div
ref={menuRef}
onKeyDown={onKeyDown}
role="menu"
aria-label={t("more")}
className="absolute end-0 top-full mt-1 z-50 min-w-[200px] max-w-[calc(100vw-2rem)] rounded-md border border-border bg-background py-1 shadow-lg"
>
{appItems && (
<>
{appItems}
<ContextMenuSeparator />
</>
)}
<ContextMenuItem
icon={Bell}
label={tNotifications("title")}
shortcut={unread > 0 ? formatBadgeCount(unread) : undefined}
onClick={() => { close(); setShowNotifications(true); }}
/>
<ContextMenuItem
icon={Terminal}
label={tPalette("title")}
onClick={() => { close(); openCommandPalette(); }}
/>
<PluginActions surface="top-bar-actions" context={TOP_BAR_CONTEXT} variant="menu" onRan={close} />
</div>
)}
{showNotifications && <NotificationSheet onClose={() => setShowNotifications(false)} />}
</div>
);
}
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 (
<div data-phone-header-actions="" className={cn("flex items-center gap-1 flex-shrink-0 ms-auto", className)}>
<PhoneHeaderMenu menuItems={menuItems} />
{/* 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. */}
<AccountSwitcher
variant="header"
onSelectAllInboxes={onSelectAllInboxes}
allInboxesSelected={allInboxesSelected}
/>
</div>
);
}
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 (
<header className="flex items-center gap-2 h-14 shrink-0 border-b border-border bg-background px-4">
{leading}
<h1 className="font-semibold text-lg truncate min-w-0" title={title}>{title}</h1>
<PhoneHeaderActions {...actions} />
</header>
);
}
+2
View File
@@ -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) {
</div>
)}
<div className="flex flex-col flex-1 min-w-0">
{!isEmbedded && <PhoneAppHeader title={page?.title ?? ""} below="md" />}
{inlineApp && <InlineAppView apps={loadedApps} activeAppId={inlineApp.id} onClose={closeInlineApp} />}
<div className={cn("flex flex-col flex-1 min-h-0", inlineApp && "hidden")}>
{content}
+3
View File
@@ -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 =
<ArrowLeft className="w-5 h-5" />
</Button>
<h1 className="font-semibold text-lg truncate">{activeTabLabel}</h1>
<PhoneHeaderActions />
</div>
<div className="flex-1 overflow-y-auto p-4">
@@ -880,6 +882,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
<SettingsIcon className="w-5 h-5 text-muted-foreground" />
<h1 className="font-semibold text-lg">{t('title')}</h1>
</div>
<PhoneHeaderActions />
</div>
<div className="flex-1 overflow-y-auto">