diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx index 6d11707e8..24c0c58c8 100644 --- a/components/layout/account-switcher.tsx +++ b/components/layout/account-switcher.tsx @@ -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 ? ( <> - + {variant === "expanded" && ( <>
@@ -224,7 +237,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher ) : (
?
diff --git a/components/layout/mobile-header.tsx b/components/layout/mobile-header.tsx index 6f01d4e8a..9c7006e02 100644 --- a/components/layout/mobile-header.tsx +++ b/components/layout/mobile-header.tsx @@ -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 */} -
+
+ {onOpenSearch && ( + /* Not a single button: the clear control is a button of its own, and + nesting one inside another is invalid markup. */ +
+ + {searchActive && onClearSearch && ( + + )} +
+ )} + {/* Right actions */} -
+
{onSearch && ( )} + + {/* 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. */} +
); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 32e44bdaf..8310c50bc 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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 = {}) { setMobileSearchOpen(true) : undefined} + searchPlaceholder={searchQuery || t('sidebar.search_placeholder_hint')} + searchActive={!!searchQuery} + onClearSearch={handleClearSearch} /> - {/* Search Bar + Inline Advanced Filters */} -
+ {/* 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) && ( +
+ {isMobile && mobileSearchOpen && ( +
+ + {t('sidebar.search_placeholder_hint')} +
+ )}
{/* Select / Select All toggle */} @@ -3636,9 +3665,13 @@ export function MailApp({ linkSegments }: MailAppProps = {}) { { 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 = {}) {
)}
+ )} {(searchQuery || !isFilterEmpty(searchFilters)) && !activeIsLoading && !isScheduledView && (
diff --git a/components/search/search-box.tsx b/components/search/search-box.tsx index 19534c6d9..439910cd9 100644 --- a/components/search/search-box.tsx +++ b/components/search/search-box.tsx @@ -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, {