mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 08:15:38 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user