From fe948f79315efbd8cf28f3baa80b766541057f7b Mon Sep 17 00:00:00 2001 From: Shuki Vaknin Date: Tue, 1 Sep 2026 18:23:36 +0300 Subject: [PATCH 1/4] fix(account-switcher): add a header variant that stays on screen The rail variant flies its popover out sideways, which is right for a narrow vertical sidebar but wrong at the top of the screen: anchored to a trigger at the inline-end edge it runs straight off the viewport. In RTL that edge is the left one, so the menu opened past the left border and was unreachable. Add a "header" variant that drops the menu below the trigger and pins it by its trailing edge, with a max-width so it can never exceed the viewport. rail and expanded are untouched. --- components/layout/account-switcher.tsx | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) 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 ) : (
?
From 3924e30572bd4c575433a9f18ed5abac9b62f44e Mon Sep 17 00:00:00 2001 From: Shuki Vaknin Date: Tue, 1 Sep 2026 18:23:49 +0300 Subject: [PATCH 2/4] feat(mobile): search-first header, replacing the second toolbar row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phones stacked two 56px bars: the mailbox title, then a row holding select-all, the search box, the filter toggle and refresh. That is 112px of chrome before the first message, and the title merely repeats what the drawer already shows. The header now carries the account avatar and a tappable search field, and the second row is presented full-screen when that field is tapped — so the same SearchBox, suggestions, history and filter chips appear, none of it rebuilt. Select-all and refresh remain in that panel, and the list already supports long-press selection and pull-to-refresh. Tablet and desktop are unchanged; the row is only relocated on phones. --- components/layout/mobile-header.tsx | 37 +++++++++++++++++++++++++---- components/mail/mail-app.tsx | 34 +++++++++++++++++++++++--- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/components/layout/mobile-header.tsx b/components/layout/mobile-header.tsx index 6f01d4e8a..c1c3e4bea 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,14 @@ 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; } export function MobileHeader({ @@ -26,6 +35,8 @@ export function MobileHeader({ onSearch, className, sidebarId, + onOpenSearch, + searchPlaceholder, }: MobileHeaderProps) { const t = useTranslations('sidebar'); const { toggleSidebar, goBack, sidebarOpen } = useUIStore(); @@ -54,7 +65,7 @@ export function MobileHeader({ )} > {/* Left action: Menu or Back button */} -
+
+ {onOpenSearch && ( + + )} + {/* 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 2f77adda3..4edc95c70 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -87,7 +87,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"; @@ -130,6 +130,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(""); @@ -3544,10 +3547,34 @@ export function MailApp({ linkSegments }: MailAppProps = {}) { setMobileSearchOpen(true) : undefined} + searchPlaceholder={t('sidebar.search_placeholder_hint')} /> - {/* 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 */} @@ -3797,6 +3824,7 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
)}
+ )} {(searchQuery || !isFilterEmpty(searchFilters)) && !activeIsLoading && !isScheduledView && (
From b6af6c16d35999559d48f7b0d0e6f3da5eb5ad96 Mon Sep 17 00:00:00 2001 From: Shuki Vaknin Date: Tue, 1 Sep 2026 18:36:01 +0300 Subject: [PATCH 3/4] fix(mobile): dismiss the search panel once a search runs The full-screen panel is fixed inset-0, so it sat on top of the very list it filters: submitting a query worked, updated the list underneath, and left the reader staring at an empty panel. Close it on submit, on picking a contact suggestion, and on clear, so the results it just produced are what you land on. The header field then shows the active query rather than the placeholder, so a filtered list does not look unexplained, and tapping it reopens the panel to refine. SearchBox gains an autoFocus prop so the panel opens straight to the keyboard instead of needing a second tap. --- components/mail/mail-app.tsx | 12 ++++++++---- components/search/search-box.tsx | 5 ++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 4edc95c70..196c68b3f 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -3548,7 +3548,7 @@ export function MailApp({ linkSegments }: MailAppProps = {}) { title={currentMailboxName} sidebarId={sidebarId} onOpenSearch={isMobile ? () => setMobileSearchOpen(true) : undefined} - searchPlaceholder={t('sidebar.search_placeholder_hint')} + searchPlaceholder={searchQuery || t('sidebar.search_placeholder_hint')} /> {/* Search Bar + Inline Advanced Filters. On phones this is not a @@ -3613,9 +3613,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} /> 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, { From 4279c37bb780e2643ac1bd736c9460453c5b83f7 Mon Sep 17 00:00:00 2001 From: Shuki Vaknin Date: Tue, 1 Sep 2026 18:40:24 +0300 Subject: [PATCH 4/4] feat(mobile): clear the query from the header search field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once a search runs the header field shows the active query, but the only way back to the full list was to open the panel and clear it there. Put a clear button at the trailing end of the field, shown only while a query is applied. The field is now a container rather than one button, since nesting a button inside a button is invalid markup — the label area and the clear control are siblings. --- components/layout/mobile-header.tsx | 38 ++++++++++++++++++++++------- components/mail/mail-app.tsx | 2 ++ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/components/layout/mobile-header.tsx b/components/layout/mobile-header.tsx index c1c3e4bea..9c7006e02 100644 --- a/components/layout/mobile-header.tsx +++ b/components/layout/mobile-header.tsx @@ -25,6 +25,10 @@ interface MobileHeaderProps { 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({ @@ -37,6 +41,8 @@ export function MobileHeader({ sidebarId, onOpenSearch, searchPlaceholder, + onClearSearch, + searchActive = false, }: MobileHeaderProps) { const t = useTranslations('sidebar'); const { toggleSidebar, goBack, sidebarOpen } = useUIStore(); @@ -93,15 +99,29 @@ export function MobileHeader({
{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 */} diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 196c68b3f..995ee9484 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -3549,6 +3549,8 @@ export function MailApp({ linkSegments }: MailAppProps = {}) { sidebarId={sidebarId} onOpenSearch={isMobile ? () => setMobileSearchOpen(true) : undefined} searchPlaceholder={searchQuery || t('sidebar.search_placeholder_hint')} + searchActive={!!searchQuery} + onClearSearch={handleClearSearch} /> {/* Search Bar + Inline Advanced Filters. On phones this is not a