Merge pull request #945 from shukiv/feat/mobile-search-first-header

feat(mobile): search-first header, replacing the second toolbar row
This commit is contained in:
Linus Rath
2026-09-07 17:47:16 +02:00
committed by GitHub
4 changed files with 116 additions and 17 deletions
+19 -6
View File
@@ -15,7 +15,7 @@ import { useMenuNavigation } from "@/hooks/use-menu-navigation";
interface AccountSwitcherProps {
/** "rail" = small avatar only (NavigationRail), "expanded" = avatar + name + email (Sidebar) */
variant?: "rail" | "expanded";
variant?: "rail" | "expanded" | "header";
className?: string;
}
@@ -66,7 +66,20 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
if (!buttonRef.current) return;
const rect = buttonRef.current.getBoundingClientRect();
const rtl = isDocumentRTL();
if (variant === "rail") {
if (variant === "header") {
// Anchored at the inline-end edge of the top bar, so the menu drops
// down and is pinned by its own trailing edge — anchoring by the
// leading edge (as "expanded" does) runs it off-screen, which is
// exactly what happened in RTL where that edge is the left one.
setPopoverStyle({
position: "fixed",
top: rect.bottom + 4,
maxWidth: "calc(100vw - 16px)",
...(rtl
? { left: Math.max(8, rect.left) }
: { right: Math.max(8, window.innerWidth - rect.right) }),
});
} else if (variant === "rail") {
setPopoverStyle(
rtl
? {
@@ -197,12 +210,12 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
data-active-account-id={activeAccountId ?? undefined}
className={cn(
"flex items-center gap-2 rounded-md transition-colors",
variant === "rail"
variant !== "expanded"
? "justify-center w-10 h-10 hover:bg-muted"
: "w-full px-2 py-1.5 hover:bg-muted text-start min-w-0",
className
)}
title={variant === "rail" ? (displayName || displayEmail) : undefined}
title={variant !== "expanded" ? (displayName || displayEmail) : undefined}
aria-label={t("switch_account")}
aria-expanded={open}
aria-haspopup="menu"
@@ -210,7 +223,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
>
{activeAccount ? (
<>
<AccountAvatar account={activeAccount} size={variant === "rail" ? "sm" : "md"} />
<AccountAvatar account={activeAccount} size={variant === "expanded" ? "md" : "sm"} />
{variant === "expanded" && (
<>
<div className="min-w-0 flex-1">
@@ -224,7 +237,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
) : (
<div className={cn(
"rounded-full bg-muted flex items-center justify-center text-muted-foreground",
variant === "rail" ? "w-8 h-8 text-xs" : "w-9 h-9 text-sm"
variant === "expanded" ? "w-9 h-9 text-sm" : "w-8 h-8 text-xs"
)}>
?
</div>
+53 -4
View File
@@ -6,6 +6,7 @@ import { useUIStore } from "@/stores/ui-store";
import { useIsDesktop } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
import { AccountSwitcher } from "@/components/layout/account-switcher";
interface MobileHeaderProps {
title: string;
@@ -16,6 +17,18 @@ interface MobileHeaderProps {
className?: string;
/** Id of the sidebar this header's menu button toggles. */
sidebarId?: string;
/**
* When set, the mailbox title is replaced by a tappable search field and
* this fires instead. The folder name stays visible in the drawer, so the
* bar spends its width on the thing you act on rather than a label.
*/
onOpenSearch?: () => void;
/** Placeholder shown inside the search field. */
searchPlaceholder?: string;
/** Clears the active query without opening the search panel. */
onClearSearch?: () => void;
/** Whether a query is currently applied, which reveals the clear button. */
searchActive?: boolean;
}
export function MobileHeader({
@@ -26,6 +39,10 @@ export function MobileHeader({
onSearch,
className,
sidebarId,
onOpenSearch,
searchPlaceholder,
onClearSearch,
searchActive = false,
}: MobileHeaderProps) {
const t = useTranslations('sidebar');
const { toggleSidebar, goBack, sidebarOpen } = useUIStore();
@@ -54,7 +71,7 @@ export function MobileHeader({
)}
>
{/* Left action: Menu or Back button */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 min-w-0 flex-shrink-0">
<Button
variant="ghost"
size="icon"
@@ -77,12 +94,38 @@ export function MobileHeader({
)}
</Button>
{/* Title */}
<h1 className="font-semibold text-lg truncate">{title}</h1>
{/* Title, unless the bar is in search-first mode */}
{!onOpenSearch && <h1 className="font-semibold text-lg truncate">{title}</h1>}
</div>
{onOpenSearch && (
/* Not a single button: the clear control is a button of its own, and
nesting one inside another is invalid markup. */
<div className="flex-1 min-w-0 h-10 mx-1 flex items-center rounded-full bg-muted ps-3 pe-1">
<button
type="button"
onClick={onOpenSearch}
className="flex-1 min-w-0 h-full flex items-center gap-2 text-start text-muted-foreground"
aria-label={searchPlaceholder || title}
>
<Search className="h-4 w-4 flex-shrink-0" />
<span className="truncate text-sm">{searchPlaceholder || title}</span>
</button>
{searchActive && onClearSearch && (
<button
type="button"
onClick={onClearSearch}
className="h-8 w-8 flex-shrink-0 grid place-items-center rounded-full text-muted-foreground hover:bg-background/60"
aria-label={t('clear_search')}
>
<X className="h-4 w-4" />
</button>
)}
</div>
)}
{/* Right actions */}
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 flex-shrink-0">
{onSearch && (
<Button
variant="ghost"
@@ -105,6 +148,12 @@ export function MobileHeader({
<Plus className="h-5 w-5" />
</Button>
)}
{/* Which account you are reading is otherwise only visible after
opening the drawer. Last in this group so it sits on the far
edge — opposite the menu button, and mirrored under RTL because
the header is laid out with flex rather than fixed sides. */}
<AccountSwitcher variant="header" />
</div>
</header>
);
+40 -6
View File
@@ -88,7 +88,7 @@ import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { findDraftIdentityId, resolveComposeAccountEmail, resolveReplyFrom, type ReplyFromResolution } from "@/lib/reply-identity";
import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients";
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
import { Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
import { Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle, ArrowLeft } from "lucide-react";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { Button } from "@/components/ui/button";
import { useConfig } from "@/hooks/use-config";
@@ -131,6 +131,9 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
const tQuote = useTranslations('quote_header');
const { appName } = useConfig();
const mailLayout = useSettingsStore((state) => state.mailLayout);
// Phones present search full-screen from the header field rather than
// giving it a permanent second bar under the header.
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
const [showComposer, setShowComposer] = useState(false);
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
const [composerDraftText, setComposerDraftText] = useState("");
@@ -3594,10 +3597,36 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
<MobileHeader
title={currentMailboxName}
sidebarId={sidebarId}
onOpenSearch={isMobile ? () => setMobileSearchOpen(true) : undefined}
searchPlaceholder={searchQuery || t('sidebar.search_placeholder_hint')}
searchActive={!!searchQuery}
onClearSearch={handleClearSearch}
/>
{/* Search Bar + Inline Advanced Filters */}
<div className="border-b border-border bg-background">
{/* Search Bar + Inline Advanced Filters. On phones this is not a
permanent second bar: the header carries a search field, and
tapping it presents this panel full-screen. Select-all and
refresh stay reachable there too, and long-press / pull-to-
refresh already cover them on the list itself. */}
{(!isMobile || mobileSearchOpen) && (
<div className={cn(
"border-b border-border bg-background",
isMobile && mobileSearchOpen && "fixed inset-0 z-50 overflow-y-auto"
)}>
{isMobile && mobileSearchOpen && (
<div className="h-14 px-2 flex items-center border-b border-border">
<Button
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => setMobileSearchOpen(false)}
aria-label={t('sidebar.mobile.go_back')}
>
<ArrowLeft className="h-5 w-5 rtl:-scale-x-100" />
</Button>
<span className="font-medium truncate">{t('sidebar.search_placeholder_hint')}</span>
</div>
)}
<div className="px-3 h-14 flex items-center">
<div className="flex items-center gap-1.5 w-full">
{/* Select / Select All toggle */}
@@ -3636,9 +3665,13 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
<SearchBox
value={searchQuery}
onChange={setSearchQuery}
onSubmit={handleSearch}
onClear={handleClearSearch}
onSelectContact={handleSelectContactSuggestion}
autoFocus={isMobile && mobileSearchOpen}
onSubmit={(query) => { handleSearch(query); setMobileSearchOpen(false); }}
onClear={() => { handleClearSearch(); setMobileSearchOpen(false); }}
onSelectContact={(contact, field) => {
handleSelectContactSuggestion(contact, field);
setMobileSearchOpen(false);
}}
disabled={isScheduledView}
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
/>
@@ -3847,6 +3880,7 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
</div>
)}
</div>
)}
{(searchQuery || !isFilterEmpty(searchFilters)) && !activeIsLoading && !isScheduledView && (
<div className="px-4 py-1.5 text-xs text-muted-foreground border-b border-border bg-muted/20">
+4 -1
View File
@@ -22,6 +22,8 @@ interface SearchBoxProps {
onSelectContact: (contact: ContactSuggestion, field: ContactSearchField) => void;
disabled?: boolean;
title?: string;
/** Focus on mount — the mobile search panel opens straight to the keyboard. */
autoFocus?: boolean;
}
/**
@@ -31,7 +33,7 @@ interface SearchBoxProps {
* reuses the composer's contact autocomplete lookup so directory principals,
* address-book contacts and recent recipients all surface here too.
*/
export function SearchBox({ value, onChange, onSubmit, onClear, onSelectContact, disabled = false, title }: SearchBoxProps) {
export function SearchBox({ value, onChange, onSubmit, onClear, onSelectContact, disabled = false, title, autoFocus }: SearchBoxProps) {
const t = useTranslations("sidebar");
const listId = `search-suggestions-${useId().replace(/:/g, "")}`;
@@ -118,6 +120,7 @@ export function SearchBox({ value, onChange, onSubmit, onClear, onSelectContact,
<Search className="absolute start-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="text"
autoFocus={autoFocus}
placeholder={t("search_placeholder_hint")}
value={value}
onChange={(e) => {