Add better active filter state with clear filter action (#1473)

This commit is contained in:
Leendert de Borst
2026-01-21 14:34:05 +01:00
parent 9178db2326
commit f9abb34e5a
6 changed files with 277 additions and 25 deletions

View File

@@ -685,19 +685,59 @@ const ItemsList: React.FC = () => {
)}
</>
) : filteredItems.length === 0 && folders.length === 0 ? (
<div className="text-gray-500 dark:text-gray-400 space-y-2 mb-10">
<div className="text-gray-500 dark:text-gray-400 space-y-3 mb-10">
{/* Show filter/search-specific messages only when actively filtering or searching */}
{(filterType !== 'all' || searchTerm) && (
<p>
{filterType === 'passkeys'
? t('items.noPasskeysFound')
: filterType === 'attachments'
? t('items.noAttachmentsFound')
: isItemTypeFilter(filterType)
? t('items.noItemsOfTypeFound', { type: getFilterTitle() })
: t('items.noMatchingItems')
}
</p>
<>
<p>
{/* Different messages based on what's causing no results */}
{searchTerm && filterType !== 'all'
// Both search and filter active
? t('items.noMatchingItemsWithFilter', { filter: getFilterTitle(), search: searchTerm })
: searchTerm
// Only search active
? t('items.noMatchingItemsSearch', { search: searchTerm })
// Only filter active (no search)
: filterType === 'passkeys'
? t('items.noPasskeysFound')
: filterType === 'attachments'
? t('items.noAttachmentsFound')
: isItemTypeFilter(filterType)
? t('items.noItemsOfTypeFound', { type: getFilterTitle() })
: t('items.noMatchingItems')
}
</p>
{/* Clear filter/search buttons */}
<div className="flex flex-wrap gap-2">
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
{t('items.clearSearch')}
</button>
)}
{filterType !== 'all' && (
<button
onClick={() => {
setFilterType('all');
localStorage.removeItem(FILTER_STORAGE_KEY);
}}
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm bg-orange-100 dark:bg-orange-900/30 hover:bg-orange-200 dark:hover:bg-orange-900/50 text-orange-700 dark:text-orange-300 rounded-lg transition-colors"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
{t('items.clearFilter')}
</button>
)}
</div>
</>
)}
{/* Show help text when inside an empty folder */}
{currentFolderId && (
@@ -757,6 +797,39 @@ const ItemsList: React.FC = () => {
</ul>
)}
{/* Clear filter/search pills at bottom of list when filtering or searching */}
{(filterType !== 'all' || searchTerm) && (
<div className="flex flex-wrap justify-center gap-2 mt-4 pt-4">
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
{t('items.clearSearch')}
</button>
)}
{filterType !== 'all' && (
<button
onClick={() => {
setFilterType('all');
localStorage.removeItem(FILTER_STORAGE_KEY);
}}
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm bg-orange-100 dark:bg-orange-900/30 hover:bg-orange-200 dark:hover:bg-orange-900/50 text-orange-700 dark:text-orange-300 rounded-lg transition-colors"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
{t('items.clearFilter')}
</button>
)}
</div>
)}
{/* Recently Deleted link (only show at root level when not searching and not filtering) */}
{!currentFolderId && !searchTerm && filterType === 'all' && (
<button

View File

@@ -189,6 +189,10 @@
"noAttachmentsFound": "No items with attachments found",
"noItemsOfTypeFound": "No {{type}} items found",
"noMatchingItems": "No matching items found",
"noMatchingItemsSearch": "No items matching \"{{search}}\"",
"noMatchingItemsWithFilter": "No {{filter}} items matching \"{{search}}\"",
"clearSearch": "Clear search",
"clearFilter": "Clear filter",
"emptyFolderHint": "This folder is empty. To move items to this folder, edit the item and tap the folder icon in the name field.",
"deleteFolder": "Delete Folder",
"deleteFolderKeepItems": "Delete folder only",

View File

@@ -540,13 +540,59 @@ export default function ItemsScreen(): React.ReactNode {
fontSize: 20,
},
// Empty state styles
emptyContainer: {
alignItems: 'center',
marginTop: 24,
},
emptyText: {
color: colors.textMuted,
fontSize: 16,
marginTop: 24,
opacity: 0.7,
textAlign: 'center',
},
clearButtonsContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
justifyContent: 'center',
marginTop: 16,
},
clearSearchButton: {
alignItems: 'center',
backgroundColor: colors.accentBackground,
borderRadius: 8,
flexDirection: 'row',
gap: 6,
paddingHorizontal: 12,
paddingVertical: 8,
},
clearSearchButtonText: {
color: colors.text,
fontSize: 14,
},
clearFilterButton: {
alignItems: 'center',
backgroundColor: colors.primary + '20',
borderRadius: 8,
flexDirection: 'row',
gap: 6,
paddingHorizontal: 12,
paddingVertical: 8,
},
clearFilterButtonText: {
color: colors.primary,
fontSize: 14,
},
// Footer clear buttons styles (at bottom of list)
footerClearContainer: {
alignItems: 'center',
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
justifyContent: 'center',
marginTop: 16,
paddingTop: 16,
},
// FAB styles
fab: {
alignItems: 'center',
@@ -805,20 +851,95 @@ export default function ItemsScreen(): React.ReactNode {
return null;
}
/**
* Determine the appropriate message based on search and filter state.
*/
const getMessage = (): string => {
// Both search and filter active
if (searchQuery && filterType !== 'all') {
return t('items.noMatchingItemsWithFilter', { filter: getFilterTitle(), search: searchQuery });
}
// Only search active
if (searchQuery) {
return t('items.noMatchingItemsSearch', { search: searchQuery });
}
// Only filter active (no search)
if (filterType === 'passkeys') {
return t('items.noPasskeysFound');
}
if (filterType === 'attachments') {
return t('items.noAttachmentsFound');
}
if (isItemTypeFilter(filterType)) {
return t('items.noItemsOfTypeFound', { type: getFilterTitle() });
}
// No search, no filter - truly empty vault
return t('items.noItemsFound');
};
const showClearButtons = searchQuery || filterType !== 'all';
return (
<View>
<Text style={styles.emptyText}>
{searchQuery
? t('items.noMatchingItems')
: filterType === 'passkeys'
? t('items.noPasskeysFound')
: filterType === 'attachments'
? t('items.noAttachmentsFound')
: isItemTypeFilter(filterType)
? t('items.noItemsOfTypeFound', { type: getFilterTitle() })
: t('items.noItemsFound')
}
</Text>
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>{getMessage()}</Text>
{/* Clear search/filter buttons */}
{showClearButtons && (
<View style={styles.clearButtonsContainer}>
{searchQuery && (
<TouchableOpacity
style={styles.clearSearchButton}
onPress={() => setSearchQuery('')}
>
<MaterialIcons name="close" size={16} color={colors.text} />
<Text style={styles.clearSearchButtonText}>{t('items.clearSearch')}</Text>
</TouchableOpacity>
)}
{filterType !== 'all' && (
<TouchableOpacity
style={styles.clearFilterButton}
onPress={() => setFilterType('all')}
>
<MaterialIcons name="close" size={16} color={colors.primary} />
<Text style={styles.clearFilterButtonText}>{t('items.clearFilter')}</Text>
</TouchableOpacity>
)}
</View>
)}
</View>
);
};
/**
* Render list footer with clear filter/search buttons.
* Only shown when there are items and a filter or search is active.
*/
const renderListFooter = (): React.ReactNode => {
// Don't show footer if loading, no items, or no active filter/search
if (isLoadingItems || filteredItems.length === 0 || (filterType === 'all' && !searchQuery)) {
return null;
}
return (
<View style={styles.footerClearContainer}>
{searchQuery && (
<TouchableOpacity
style={styles.clearSearchButton}
onPress={() => setSearchQuery('')}
>
<MaterialIcons name="close" size={16} color={colors.text} />
<Text style={styles.clearSearchButtonText}>{t('items.clearSearch')}</Text>
</TouchableOpacity>
)}
{filterType !== 'all' && (
<TouchableOpacity
style={styles.clearFilterButton}
onPress={() => setFilterType('all')}
>
<MaterialIcons name="close" size={16} color={colors.primary} />
<Text style={styles.clearFilterButtonText}>{t('items.clearFilter')}</Text>
</TouchableOpacity>
)}
</View>
);
};
@@ -872,6 +993,7 @@ export default function ItemsScreen(): React.ReactNode {
)
}
ListEmptyComponent={renderEmptyComponent() as React.ReactElement}
ListFooterComponent={renderListFooter() as React.ReactElement}
/>
</ThemedView>

View File

@@ -520,10 +520,14 @@
"publicEmailDescription": "Anonymous but limited privacy. Email content is readable by anyone that knows the address.",
"searchPlaceholder": "Search vault...",
"noMatchingItems": "No matching items found",
"noMatchingItemsSearch": "No items matching \"{{search}}\"",
"noMatchingItemsWithFilter": "No {{filter}} items matching \"{{search}}\"",
"noItemsFound": "No items found. Create one to get started. Tip: you can also login to the AliasVault web app to import credentials from other password managers.",
"noPasskeysFound": "No passkeys have been created yet. Passkeys are created by visiting a website that offers passkeys as an authentication method.",
"noAttachmentsFound": "No items with attachments found",
"noItemsOfTypeFound": "No {{type}} items found",
"clearSearch": "Clear search",
"clearFilter": "Clear filter",
"recentEmails": "Recent emails",
"loadingEmails": "Loading emails...",
"noEmailsYet": "No emails received yet.",

View File

@@ -152,6 +152,20 @@ else
@if (DbService.Settings.CredentialsViewMode == "table")
{
<div class="px-4 min-h-[250px]">
@* Active filter indicator - show persistently when a filter is active *@
@if (FilterType != ItemFilterType.All)
{
<div class="flex flex-wrap items-center gap-2 mb-4">
<span class="text-sm text-gray-500 dark:text-gray-400">@Localizer["FilteringBy"]</span>
<button @onclick="ClearFilter" class="inline-flex items-center gap-1 px-3 py-1.5 text-sm bg-orange-100 dark:bg-orange-900/30 hover:bg-orange-200 dark:hover:bg-orange-900/50 text-orange-700 dark:text-orange-300 rounded-lg transition-colors">
<span>@GetFilterTitle()</span>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
}
<ItemsTable Credentials="@FilteredAndSortedItems.ToList()" SortOrder="@SortOrder" />
@* Infinite scroll sentinel for table view *@
@if (HasMoreItems)
@@ -174,6 +188,21 @@ else
else
{
<div class="px-4 mb-4 min-h-[250px]">
@* Active filter indicator - show persistently when a filter is active *@
@if (FilterType != ItemFilterType.All)
{
<div class="flex flex-wrap items-center gap-2 mb-4">
<span class="text-sm text-gray-500 dark:text-gray-400">@Localizer["FilteringBy"]</span>
<button @onclick="ClearFilter" class="inline-flex items-center gap-1 px-3 py-1.5 text-sm bg-orange-100 dark:bg-orange-900/30 hover:bg-orange-200 dark:hover:bg-orange-900/50 text-orange-700 dark:text-orange-300 rounded-lg transition-colors">
<span>@GetFilterTitle()</span>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
}
@* Folders section - only show at root level *@
@if (!IsInFolder && !IsSearching)
{
@@ -544,6 +573,16 @@ else
StateHasChanged();
}
/// <summary>
/// Clears the active filter and resets to showing all items.
/// </summary>
private void ClearFilter()
{
FilterType = ItemFilterType.All;
VisibleItemCount = BatchSize; // Reset visible items when filter changes
StateHasChanged();
}
/// <summary>
/// Loads more items for infinite scroll. Called from JavaScript via IntersectionObserver.
/// </summary>

View File

@@ -236,4 +236,14 @@
<value>Loading more...</value>
<comment>Text shown when loading more items during infinite scroll</comment>
</data>
<!-- Clear Filter -->
<data name="ClearFilter" xml:space="preserve">
<value>Clear filter</value>
<comment>Button text for clearing the active filter</comment>
</data>
<data name="FilteringBy" xml:space="preserve">
<value>Filtering by:</value>
<comment>Label shown when a filter is active</comment>
</data>
</root>