mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 00:14:12 +00:00
209 lines
7.9 KiB
TypeScript
209 lines
7.9 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useId, useMemo, useRef, useState } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import { Search, X } from "@/components/icons";
|
|
import { cn } from "@/lib/utils";
|
|
import { Input } from "@/components/ui/input";
|
|
import { useContactStore } from "@/stores/contact-store";
|
|
import { useSearchHistoryStore } from "@/stores/search-history-store";
|
|
import { buildSearchSuggestions, type ContactSuggestion, type SearchSuggestion } from "@/lib/search-suggestions";
|
|
import { SearchSuggestions, suggestionOptionId } from "./search-suggestions";
|
|
|
|
export type ContactSearchField = "from" | "to";
|
|
|
|
interface SearchBoxProps {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
/** Submit a free-text search (Enter, or picking a recent search). */
|
|
onSubmit: (query: string) => void;
|
|
onClear: () => void;
|
|
/** Run a sender / recipient search for a suggested contact. */
|
|
onSelectContact: (contact: ContactSuggestion, field: ContactSearchField) => void;
|
|
disabled?: boolean;
|
|
title?: string;
|
|
/** Focus on mount — the mobile search panel opens straight to the keyboard. */
|
|
autoFocus?: boolean;
|
|
/** Overrides the default placeholder, which carries the `/` shortcut hint. */
|
|
placeholder?: string;
|
|
}
|
|
|
|
/**
|
|
* The mail search bar (#845): a text input with a suggestion dropdown that
|
|
* offers recent searches and contact / sender matches while typing. The
|
|
* dropdown is keyboard-navigable (arrows + Enter, Escape to dismiss) and
|
|
* 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, autoFocus, placeholder }: SearchBoxProps) {
|
|
const t = useTranslations("sidebar");
|
|
const listId = `search-suggestions-${useId().replace(/:/g, "")}`;
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [open, setOpen] = useState(false);
|
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
|
// The text being typed belongs to the box until it is submitted, cleared or
|
|
// the box loses focus. `value` is the search in force; handing it every
|
|
// keystroke re-rendered the whole mail view per character typed.
|
|
const [draft, setDraft] = useState(value);
|
|
const [adopted, setAdopted] = useState(value);
|
|
if (value !== adopted) {
|
|
setAdopted(value);
|
|
setDraft(value);
|
|
}
|
|
const commitDraft = () => {
|
|
if (draft !== value) onChange(draft);
|
|
};
|
|
|
|
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
|
|
// Subscribed only so the memo below recomputes as the lookup's inputs load.
|
|
const contacts = useContactStore((s) => s.contacts);
|
|
const recentRecipients = useContactStore((s) => s.recentRecipients);
|
|
const directoryPrincipals = useContactStore((s) => s.directoryPrincipals);
|
|
const recentSearches = useSearchHistoryStore((s) => s.recentSearches);
|
|
const removeRecentSearch = useSearchHistoryStore((s) => s.removeRecentSearch);
|
|
|
|
const suggestions = useMemo<SearchSuggestion[]>(() => {
|
|
const query = draft.trim();
|
|
const matches = query ? getAutocomplete(query).filter((r) => !r.group && r.email) : [];
|
|
return buildSearchSuggestions(draft, recentSearches, matches);
|
|
// The three store slices are inputs of getAutocomplete (read via get()),
|
|
// not of this closure — list them so the memo recomputes when they load.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [draft, recentSearches, getAutocomplete, contacts, recentRecipients, directoryPrincipals]);
|
|
|
|
const showDropdown = open && !disabled && suggestions.length > 0;
|
|
const activeIndex = showDropdown && selectedIndex < suggestions.length ? selectedIndex : -1;
|
|
|
|
const close = useCallback(() => {
|
|
setOpen(false);
|
|
setSelectedIndex(-1);
|
|
}, []);
|
|
|
|
const select = useCallback(
|
|
(suggestion: SearchSuggestion) => {
|
|
close();
|
|
if (suggestion.kind === "recent") {
|
|
setDraft(suggestion.query);
|
|
onChange(suggestion.query);
|
|
onSubmit(suggestion.query);
|
|
} else {
|
|
onSelectContact({ name: suggestion.name, email: suggestion.email }, "from");
|
|
}
|
|
},
|
|
[close, onChange, onSubmit, onSelectContact],
|
|
);
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (!showDropdown) {
|
|
if (e.key === "ArrowDown" && suggestions.length > 0 && !disabled) {
|
|
e.preventDefault();
|
|
setOpen(true);
|
|
setSelectedIndex(0);
|
|
}
|
|
return;
|
|
}
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setSelectedIndex((prev) => Math.min(prev + 1, suggestions.length - 1));
|
|
} else if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setSelectedIndex((prev) => Math.max(prev - 1, -1));
|
|
} else if (e.key === "Enter" && activeIndex >= 0) {
|
|
e.preventDefault();
|
|
select(suggestions[activeIndex]);
|
|
} else if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
close();
|
|
} else if (e.key === "Tab") {
|
|
close();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative flex-1">
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
if (draft.trim()) {
|
|
close();
|
|
onSubmit(draft);
|
|
} else if (value) {
|
|
// Enter on an emptied box leaves the search, as the clear button does.
|
|
close();
|
|
onClear();
|
|
}
|
|
}}
|
|
className="relative"
|
|
>
|
|
<Search className="absolute start-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
{/* type="search": Bitwarden skips search fields unconditionally (it
|
|
honours data-bwignore only when the user opted in) and otherwise
|
|
offered logins here. */}
|
|
<Input
|
|
type="search"
|
|
autoFocus={autoFocus}
|
|
placeholder={placeholder ?? t("search_placeholder_hint")}
|
|
value={draft}
|
|
onChange={(e) => {
|
|
setDraft(e.target.value);
|
|
setOpen(true);
|
|
setSelectedIndex(-1);
|
|
}}
|
|
onFocus={() => setOpen(true)}
|
|
onClick={() => setOpen(true)}
|
|
onBlur={(e) => {
|
|
// Focus moving into the dropdown (e.g. its action buttons) keeps it open.
|
|
const next = e.relatedTarget as Node | null;
|
|
if (next && containerRef.current?.contains(next)) return;
|
|
// Anything that reads the query from outside (the filter panel's
|
|
// search) sees what was typed once focus moves to it.
|
|
commitDraft();
|
|
close();
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
className={cn("ps-9 h-9 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none", draft && "pe-8")}
|
|
role="combobox"
|
|
aria-expanded={showDropdown}
|
|
aria-controls={listId}
|
|
aria-autocomplete="list"
|
|
aria-activedescendant={activeIndex >= 0 ? suggestionOptionId(listId, activeIndex) : undefined}
|
|
autoComplete="off"
|
|
data-search-input
|
|
data-tour="search-input"
|
|
disabled={disabled}
|
|
title={title}
|
|
/>
|
|
{draft && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setDraft("");
|
|
close();
|
|
onClear();
|
|
}}
|
|
className="absolute end-2 top-1/2 transform -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
aria-label={t("clear_search")}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</form>
|
|
{showDropdown && (
|
|
<SearchSuggestions
|
|
id={listId}
|
|
query={draft}
|
|
suggestions={suggestions}
|
|
selectedIndex={activeIndex}
|
|
onSelect={select}
|
|
onSelectContactTo={(contact) => {
|
|
close();
|
|
onSelectContact(contact, "to");
|
|
}}
|
|
onRemoveRecent={removeRecentSearch}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|