feat: enhance mobile vault filter UI and improve styling for better usability

This commit is contained in:
shuaiplus
2026-06-16 21:17:43 +08:00
parent d5c2ab2b0f
commit 7e0406f751
13 changed files with 445 additions and 153 deletions
+256 -131
View File
@@ -1,9 +1,32 @@
import type { RefObject } from 'preact';
import type { ComponentChildren, RefObject } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import { memo } from 'preact/compat';
import { createPortal } from 'preact/compat';
import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact';
import {
Archive,
ArrowUpDown,
Check,
CheckCheck,
ChevronDown,
Copy,
CreditCard,
Folder as FolderIcon,
FolderInput,
FolderX,
Globe,
KeyRound,
LayoutGrid,
Plus,
RefreshCw,
RotateCcw,
ShieldUser,
Star,
StickyNote,
Trash2,
X,
} from 'lucide-preact';
import LoadingState from '@/components/LoadingState';
import type { Cipher } from '@/lib/types';
import type { Cipher, Folder } from '@/lib/types';
import { t } from '@/lib/i18n';
import {
CreateTypeIcon,
@@ -27,6 +50,7 @@ interface VaultListPanelProps {
busy: boolean;
loading: boolean;
error: string;
folders: Folder[];
searchInput: string;
sortMode: VaultSortMode;
sortMenuOpen: boolean;
@@ -53,6 +77,7 @@ interface VaultListPanelProps {
onToggleSortMenu: () => void;
onSelectSortMode: (value: VaultSortMode) => void;
onDuplicateModeChange: (value: DuplicateDetectionMode) => void;
onChangeFilter: (filter: SidebarFilter) => void;
onSyncVault: () => void;
onOpenBulkDelete: () => void;
onSelectDuplicates: () => void;
@@ -80,6 +105,16 @@ interface CipherListItemProps {
onSelectCipher: (cipherId: string) => void;
}
type MobileFilterMenuKey = 'duplicate' | 'menu' | 'type' | 'folder';
interface MobileFilterOption {
value: string;
label: string;
icon: ComponentChildren;
active: boolean;
onSelect: () => void;
}
const CipherListItem = memo(function CipherListItem(props: CipherListItemProps) {
const duplicateGroupHue = props.duplicateGroupIndex === null ? null : (props.duplicateGroupIndex * 137.508) % 360;
return (
@@ -115,14 +150,116 @@ const CipherListItem = memo(function CipherListItem(props: CipherListItemProps)
});
export default function VaultListPanel(props: VaultListPanelProps) {
const [mobileFilterOpen, setMobileFilterOpen] = useState<MobileFilterMenuKey | null>(null);
const mobileFilterRef = useRef<HTMLDivElement | null>(null);
const createTypeOptions = getCreateTypeOptions();
const duplicateDetectionOptions = getDuplicateDetectionOptions();
const vaultSortOptions = getVaultSortOptions();
const createMenu = (
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}>
const duplicateModeOptions: MobileFilterOption[] = duplicateDetectionOptions.map((option) => ({
value: option.value,
label: option.label,
icon: option.value === 'login-site' ? <Globe size={14} /> : option.value === 'exact' ? <Copy size={14} /> : <KeyRound size={14} />,
active: props.duplicateMode === option.value,
onSelect: () => props.onDuplicateModeChange(option.value),
}));
const menuFilterOptions: MobileFilterOption[] = [
{ value: 'all', label: t('txt_all_items'), icon: <LayoutGrid size={14} />, active: props.sidebarFilter.kind === 'all', onSelect: () => props.onChangeFilter({ kind: 'all' }) },
{ value: 'favorite', label: t('txt_favorites'), icon: <Star size={14} />, active: props.sidebarFilter.kind === 'favorite', onSelect: () => props.onChangeFilter({ kind: 'favorite' }) },
{ value: 'archive', label: t('txt_archive'), icon: <Archive size={14} />, active: props.sidebarFilter.kind === 'archive', onSelect: () => props.onChangeFilter({ kind: 'archive' }) },
{ value: 'trash', label: t('txt_trash'), icon: <Trash2 size={14} />, active: props.sidebarFilter.kind === 'trash', onSelect: () => props.onChangeFilter({ kind: 'trash' }) },
{ value: 'duplicates', label: t('txt_duplicates'), icon: <Copy size={14} />, active: props.sidebarFilter.kind === 'duplicates', onSelect: () => props.onChangeFilter({ kind: 'duplicates' }) },
];
const typeMobileFilterOptions: MobileFilterOption[] = [
{ value: 'login', label: t('txt_login'), icon: <Globe size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'login', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'login' }) },
{ value: 'card', label: t('txt_card'), icon: <CreditCard size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'card', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'card' }) },
{ value: 'identity', label: t('txt_identity'), icon: <ShieldUser size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'identity', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'identity' }) },
{ value: 'note', label: t('txt_note'), icon: <StickyNote size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'note', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'note' }) },
{ value: 'ssh', label: t('txt_ssh_key'), icon: <KeyRound size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'ssh', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'ssh' }) },
];
const folderMobileFilterOptions: MobileFilterOption[] = [
{ value: '__none__', label: t('txt_no_folder'), icon: <FolderX size={14} />, active: props.sidebarFilter.kind === 'folder' && props.sidebarFilter.folderId === null, onSelect: () => props.onChangeFilter({ kind: 'folder', folderId: null }) },
...props.folders.map((folder) => ({
value: folder.id,
label: folder.decName || folder.name || folder.id,
icon: <FolderIcon size={14} />,
active: props.sidebarFilter.kind === 'folder' && props.sidebarFilter.folderId === folder.id,
onSelect: () => props.onChangeFilter({ kind: 'folder', folderId: folder.id }),
})),
];
const menuFilterSelected = menuFilterOptions.find((option) => option.active);
const typeFilterSelected = typeMobileFilterOptions.find((option) => option.active);
const folderFilterSelected = folderMobileFilterOptions.find((option) => option.active);
const duplicateModeSelected = duplicateModeOptions.find((option) => option.active);
useEffect(() => {
const onPointerDown = (event: Event) => {
if (!mobileFilterOpen) return;
const target = event.target as Node | null;
if (mobileFilterRef.current && target && !mobileFilterRef.current.contains(target)) {
setMobileFilterOpen(null);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setMobileFilterOpen(null);
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [mobileFilterOpen]);
const renderMobileFilterMenu = (
key: MobileFilterMenuKey,
label: string,
selected: MobileFilterOption | undefined,
fallbackIcon: ComponentChildren,
options: MobileFilterOption[]
) => (
<div className="mobile-vault-filter-control">
<button
type="button"
className="btn btn-primary small mobile-fab-trigger"
className={`mobile-vault-filter-trigger ${mobileFilterOpen === key ? 'active' : ''}`}
aria-haspopup="menu"
aria-expanded={mobileFilterOpen === key}
onClick={() => setMobileFilterOpen((open) => open === key ? null : key)}
>
<span className="mobile-vault-filter-trigger-icon">{selected?.icon || fallbackIcon}</span>
<span className="mobile-vault-filter-trigger-label">{selected?.label || label}</span>
<ChevronDown size={13} className="mobile-vault-filter-chevron" />
</button>
{mobileFilterOpen === key && (
<div className="sort-menu mobile-vault-filter-menu" role="menu">
{options.map((option) => (
<button
key={option.value}
type="button"
className={`sort-menu-item mobile-vault-filter-item ${option.active ? 'active' : ''}`}
onClick={() => {
option.onSelect();
setMobileFilterOpen(null);
}}
role="menuitemradio"
aria-checked={option.active}
>
<span className="mobile-vault-filter-item-main">
<span className="mobile-vault-filter-item-icon">{option.icon}</span>
<span>{option.label}</span>
</span>
{option.active ? <Check size={14} /> : <span className="sort-menu-check-placeholder" />}
</button>
))}
</div>
)}
</div>
);
const createMenu = (
<div className={`create-menu-wrap ${props.isMobileLayout ? 'mobile-fab-wrap' : 'desktop-create-menu-wrap'}`} ref={props.createMenuRef}>
<button
type="button"
className={`btn btn-primary small ${props.isMobileLayout ? 'mobile-fab-trigger' : 'desktop-create-trigger'}`}
aria-label={t('txt_add')}
title={t('txt_add')}
onClick={props.onToggleCreateMenu}
@@ -144,139 +281,127 @@ export default function VaultListPanel(props: VaultListPanelProps) {
return (
<section className="list-col">
<div className="list-head">
<div className="search-input-wrap">
{props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? (
<select
className="input duplicate-mode-select duplicate-mode-head-select"
value={props.duplicateMode}
aria-label={t('txt_duplicate_detection_mode')}
onChange={(event) => props.onDuplicateModeChange((event.currentTarget as HTMLSelectElement).value as DuplicateDetectionMode)}
>
{duplicateDetectionOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
) : (
<div className="list-toolbar-stack" ref={mobileFilterRef}>
<div className={`list-head ${props.selectedCount > 0 ? 'selection-mode' : ''}`}>
{props.selectedCount > 0 ? (
<>
<input
className="search-input"
placeholder={t('txt_search_your_secure_vault')}
value={props.searchInput}
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
onCompositionStart={props.onSearchCompositionStart}
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
onKeyDown={(e) => {
if (e.key !== 'Escape' || !props.searchInput) return;
e.preventDefault();
props.onClearSearch();
}}
/>
{!!props.searchInput && (
<button
type="button"
className="search-clear-btn"
aria-label={t('txt_clear_search')}
title={t('txt_clear_search_esc')}
onClick={props.onClearSearch}
>
<X size={14} />
{props.sidebarFilter.kind !== 'duplicates' && (
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
</button>
)}
<button type="button" className="btn btn-secondary small" onClick={props.onClearSelection}>
<X size={14} className="btn-icon" /> {t('txt_cancel')}
</button>
{props.sidebarFilter.kind === 'trash' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkRestore}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
</button>
)}
{props.sidebarFilter.kind === 'archive' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkUnarchive}>
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
</button>
)}
{props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkArchive}>
<Archive size={14} className="btn-icon" /> {t('txt_archive_selected')}
</button>
)}
{props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}>
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
</button>
)}
<button type="button" className="btn btn-danger small" disabled={props.busy} onClick={props.onOpenBulkDelete}>
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
</button>
</>
) : (
<>
<div className="search-input-wrap">
{props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? (
<div className="duplicate-mode-head-menu">
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
</div>
) : (
<>
<input
className="search-input"
placeholder={t('txt_search_items_count', { count: props.totalCipherCount })}
value={props.searchInput}
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
onCompositionStart={props.onSearchCompositionStart}
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
onKeyDown={(e) => {
if (e.key !== 'Escape' || !props.searchInput) return;
e.preventDefault();
props.onClearSearch();
}}
/>
{!!props.searchInput && (
<button
type="button"
className="search-clear-btn"
aria-label={t('txt_clear_search')}
title={t('txt_clear_search_esc')}
onClick={props.onClearSearch}
>
<X size={14} />
</button>
)}
</>
)}
</div>
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout && (
<div className="duplicate-mode-head-menu">
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
</div>
)}
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
<button
type="button"
className={`btn btn-secondary small sort-trigger sort-trigger-labeled ${props.sortMenuOpen ? 'active' : ''}`}
aria-label={t('txt_sort')}
title={t('txt_sort')}
onClick={props.onToggleSortMenu}
>
<ArrowUpDown size={14} className="btn-icon" /> <span>{t('txt_sort')}</span>
</button>
{props.sortMenuOpen && (
<div className="sort-menu">
{vaultSortOptions.map((option) => (
<button
key={option.value}
type="button"
className={`sort-menu-item ${props.sortMode === option.value ? 'active' : ''}`}
onClick={() => props.onSelectSortMode(option.value)}
>
<span>{option.label}</span>
{props.sortMode === option.value ? <Check size={14} /> : <span className="sort-menu-check-placeholder" />}
</button>
))}
</div>
)}
</div>
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={props.busy || props.loading} onClick={props.onSyncVault}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
</button>
{!props.isMobileLayout && props.sidebarFilter !== undefined && createMenu}
</>
)}
</div>
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
<button
type="button"
className={`btn btn-secondary small sort-trigger ${props.sortMenuOpen ? 'active' : ''}`}
aria-label={t('txt_sort')}
title={t('txt_sort')}
onClick={props.onToggleSortMenu}
>
<ArrowUpDown size={14} className="btn-icon" />
</button>
{props.sortMenuOpen && (
<div className="sort-menu">
{vaultSortOptions.map((option) => (
<button
key={option.value}
type="button"
className={`sort-menu-item ${props.sortMode === option.value ? 'active' : ''}`}
onClick={() => props.onSelectSortMode(option.value)}
>
<span>{option.label}</span>
{props.sortMode === option.value ? <Check size={14} /> : <span className="sort-menu-check-placeholder" />}
</button>
))}
</div>
)}
</div>
<div className="list-count" title={t('txt_total_items_count', { count: props.totalCipherCount })}>
{t('txt_total_items_count', { count: props.totalCipherCount })}
</div>
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={props.busy || props.loading} onClick={props.onSyncVault}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
</button>
</div>
<div className={`toolbar actions ${props.sidebarFilter.kind === 'duplicates' ? 'duplicates-toolbar' : ''}`}>
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout && (
<select
className="input duplicate-mode-select duplicate-mode-toolbar-select"
value={props.duplicateMode}
aria-label={t('txt_duplicate_detection_mode')}
onChange={(event) => props.onDuplicateModeChange((event.currentTarget as HTMLSelectElement).value as DuplicateDetectionMode)}
>
{duplicateDetectionOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
)}
{props.sidebarFilter.kind === 'duplicates' && props.duplicateMode === 'exact' && (
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length || props.busy} onClick={props.onSelectDuplicates}>
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
</button>
)}
{props.selectedCount > 0 && props.sidebarFilter.kind === 'trash' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkRestore}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
</button>
)}
{props.selectedCount > 0 && props.sidebarFilter.kind === 'archive' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkUnarchive}>
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
</button>
)}
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkArchive}>
<Archive size={14} className="btn-icon" /> {t('txt_archive_selected')}
</button>
)}
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}>
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
</button>
)}
{props.selectedCount > 0 && (
<button type="button" className="btn btn-secondary small" onClick={props.onClearSelection}>
<X size={14} className="btn-icon" /> {t('txt_cancel')}
</button>
)}
<button type="button" className="btn btn-danger small" disabled={!props.selectedCount || props.busy} onClick={props.onOpenBulkDelete}>
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
</button>
{props.sidebarFilter.kind !== 'duplicates' && (
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
</button>
)}
{props.sidebarFilter.kind !== 'duplicates' && (
props.isMobileLayout && typeof document !== 'undefined'
? props.mobileFabVisible ? createPortal(createMenu, document.body) : null
: createMenu
{props.isMobileLayout && (
<div className="mobile-vault-filter-row" aria-label={t('txt_filter')}>
{renderMobileFilterMenu('menu', t('txt_menu'), menuFilterSelected, <LayoutGrid size={14} />, menuFilterOptions)}
{renderMobileFilterMenu('type', t('txt_type'), typeFilterSelected, <Globe size={14} />, typeMobileFilterOptions)}
{renderMobileFilterMenu('folder', t('txt_folder'), folderFilterSelected, <FolderIcon size={14} />, folderMobileFilterOptions)}
</div>
)}
</div>
{!props.selectedCount && props.isMobileLayout && props.sidebarFilter.kind !== 'duplicates' && typeof document !== 'undefined' && props.mobileFabVisible
? createPortal(createMenu, document.body)
: null}
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
{props.loading && !props.filteredCiphers.length && <LoadingState lines={7} compact />}
{!props.loading && !!props.error && !props.filteredCiphers.length && (