refactor(storage): match filter bar to Backups pattern

- Full-width search bar on its own row
- Smart Reset button (only shows when filters active)
- Simplified sort options dropdown
- Consistent flex-wrap layout with gap-x-2 gap-y-2

Maintains consistency across Proxmox pages.
This commit is contained in:
rcourtman
2025-12-26 10:53:11 +00:00
parent 9a0a39d3c1
commit b4c9785aa7
@@ -22,12 +22,9 @@ interface StorageFilterProps {
export const StorageFilter: Component<StorageFilterProps> = (props) => {
const sortOptions = props.sortOptions ?? [
{ value: 'name', label: 'Name' },
{ value: 'node', label: 'Node' },
{ value: 'type', label: 'Type' },
{ value: 'status', label: 'Status' },
{ value: 'usage', label: 'Usage %' },
{ value: 'free', label: 'Free Capacity' },
{ value: 'total', label: 'Total Capacity' },
{ value: 'free', label: 'Free' },
{ value: 'total', label: 'Total' },
];
const historyManager = createSearchHistoryManager(STORAGE_KEYS.STORAGE_SEARCH_HISTORY);
@@ -96,205 +93,202 @@ export const StorageFilter: Component<StorageFilterProps> = (props) => {
});
};
const hasActiveFilters = () =>
props.search().trim() !== '' ||
props.sortKey() !== 'name' ||
props.sortDirection() !== 'asc' ||
(props.groupBy && props.groupBy() !== 'node') ||
(props.statusFilter && props.statusFilter() !== 'all');
return (
<Card class="storage-filter mb-3" padding="sm">
<div class="flex flex-col lg:flex-row gap-3">
{/* Search Bar */}
<div class="flex gap-2 flex-1 items-center">
<div class="relative flex-1">
<input
ref={(el) => {
searchInputEl = el;
props.searchInputRef?.(el);
}}
type="text"
placeholder="Search storage or node:nodename"
value={props.search()}
onInput={(e) => props.setSearch(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
commitSearchToHistory(e.currentTarget.value);
closeHistory();
} else if (e.key === 'ArrowDown' && searchHistory().length > 0) {
e.preventDefault();
setIsHistoryOpen(true);
}
}}
onBlur={(e) => {
if (suppressBlurCommit) {
return;
}
const next = e.relatedTarget as HTMLElement | null;
const interactingWithHistory = next
? historyMenuRef?.contains(next) || historyToggleRef?.contains(next)
: false;
const interactingWithTips =
next?.getAttribute('aria-controls') === 'storage-search-help';
if (!interactingWithHistory && !interactingWithTips) {
commitSearchToHistory(e.currentTarget.value);
}
}}
class="w-full pl-9 pr-16 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500
focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all"
title="Search storage by name or filter by node"
<div class="flex flex-col gap-3">
{/* Row 1: Search Bar - Full width */}
<div class="relative w-full">
<input
ref={(el) => {
searchInputEl = el;
props.searchInputRef?.(el);
}}
type="text"
placeholder="Search storage... (e.g., local, nfs, node:pve1)"
value={props.search()}
onInput={(e) => props.setSearch(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
commitSearchToHistory(e.currentTarget.value);
closeHistory();
} else if (e.key === 'ArrowDown' && searchHistory().length > 0) {
e.preventDefault();
setIsHistoryOpen(true);
}
}}
onBlur={(e) => {
if (suppressBlurCommit) {
return;
}
const next = e.relatedTarget as HTMLElement | null;
const interactingWithHistory = next
? historyMenuRef?.contains(next) || historyToggleRef?.contains(next)
: false;
const interactingWithTips =
next?.getAttribute('aria-controls') === 'storage-search-help';
if (!interactingWithHistory && !interactingWithTips) {
commitSearchToHistory(e.currentTarget.value);
}
}}
class="w-full pl-9 pr-20 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500
focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all"
title="Search storage by name or filter by node"
/>
<svg
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
<svg
class="absolute left-3 top-2 h-4 w-4 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
</svg>
<Show when={props.search()}>
<button
type="button"
class="absolute right-14 top-1/2 -translate-y-1/2 transform p-1 rounded-full
bg-gray-200 dark:bg-gray-600 text-gray-500 dark:text-gray-300
hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/50 dark:hover:text-red-400
transition-all duration-150 active:scale-90"
onClick={() => props.setSearch('')}
onMouseDown={markSuppressCommit}
aria-label="Clear search"
title="Clear search"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<Show when={props.search()}>
<button
type="button"
class="absolute right-9 top-1/2 -translate-y-1/2 transform text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
onClick={() => props.setSearch('')}
onMouseDown={markSuppressCommit}
aria-label="Clear search"
title="Clear search"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</Show>
<div class="absolute inset-y-0 right-2 flex items-center gap-1">
<button
ref={(el) => (historyToggleRef = el)}
type="button"
class="flex h-6 w-6 items-center justify-center rounded-lg border border-transparent text-gray-400 transition-colors hover:border-gray-200 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:ring-offset-1 focus:ring-offset-white dark:text-gray-500 dark:hover:border-gray-700 dark:hover:text-gray-200 dark:focus:ring-blue-400/40 dark:focus:ring-offset-gray-900"
onClick={() =>
setIsHistoryOpen((prev) => {
const next = !prev;
if (!next) {
queueMicrotask(() => historyToggleRef?.blur());
}
return next;
})
}
onMouseDown={markSuppressCommit}
aria-haspopup="listbox"
aria-expanded={isHistoryOpen()}
title={
searchHistory().length > 0
? 'Show recent searches'
: 'No recent searches yet'
}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l2.5 1.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="sr-only">Show search history</span>
</button>
<SearchTipsPopover
popoverId="storage-search-help"
intro="Quick examples"
tips={[
{ code: 'local', description: 'Storage with "local" in the name' },
{ code: 'node:pve1', description: 'Show storage on a specific node' },
{ code: 'nfs', description: 'Find NFS storage' },
]}
footerHighlight="node:pve1 nfs"
footerText="Combine filters to zero in on exactly what you need."
triggerVariant="icon"
buttonLabel="Search tips"
openOnHover
/>
</div>
<Show when={isHistoryOpen()}>
<div
ref={(el) => (historyMenuRef = el)}
class="absolute left-0 right-0 top-full z-50 mt-2 w-full overflow-hidden rounded-lg border border-gray-200 bg-white text-sm shadow-xl dark:border-gray-700 dark:bg-gray-800"
role="listbox"
>
<Show
when={searchHistory().length > 0}
fallback={
<div class="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
Your recent storage searches will show here.
</div>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</Show>
<div class="absolute inset-y-0 right-2 flex items-center gap-1">
<button
ref={(el) => (historyToggleRef = el)}
type="button"
class={`flex h-7 w-7 items-center justify-center rounded-md transition-colors
${isHistoryOpen()
? 'bg-blue-100 dark:bg-blue-900/40 text-blue-600 dark:text-blue-400'
: 'text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-600 dark:hover:text-gray-300'
}`}
onClick={() =>
setIsHistoryOpen((prev) => {
const next = !prev;
if (!next) {
queueMicrotask(() => historyToggleRef?.blur());
}
>
<div class="max-h-52 overflow-y-auto py-1">
<For each={searchHistory()}>
{(entry) => (
<div class="flex items-center justify-between px-2 py-1.5 hover:bg-blue-50 dark:hover:bg-blue-900/20">
<button
type="button"
class="flex-1 truncate pr-2 text-left text-sm text-gray-700 transition-colors hover:text-blue-600 focus:outline-none dark:text-gray-200 dark:hover:text-blue-300"
onClick={() => {
props.setSearch(entry);
commitSearchToHistory(entry);
setIsHistoryOpen(false);
focusSearchInput();
}}
onMouseDown={markSuppressCommit}
>
{entry}
</button>
<button
type="button"
class="ml-1 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:ring-offset-1 focus:ring-offset-white dark:text-gray-500 dark:hover:bg-gray-700/70 dark:hover:text-gray-200 dark:focus:ring-blue-400/40 dark:focus:ring-offset-gray-900"
title="Remove from history"
onClick={() => deleteHistoryEntry(entry)}
onMouseDown={markSuppressCommit}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<span class="sr-only">Remove from history</span>
</button>
</div>
)}
</For>
</div>
<button
type="button"
class="flex w-full items-center justify-center gap-2 border-t border-gray-200 px-3 py-2 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 focus:outline-none dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700/80 dark:hover:text-gray-200"
onClick={clearHistory}
onMouseDown={markSuppressCommit}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3m-9 0h12"
/>
</svg>
Clear history
</button>
</Show>
</div>
</Show>
return next;
})
}
onMouseDown={markSuppressCommit}
aria-haspopup="listbox"
aria-expanded={isHistoryOpen()}
title={
searchHistory().length > 0
? 'Show recent searches'
: 'No recent searches yet'
}
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
<span class="sr-only">Show search history</span>
</button>
<SearchTipsPopover
popoverId="storage-search-help"
intro="Quick examples"
tips={[
{ code: 'local', description: 'Storage with "local" in the name' },
{ code: 'node:pve1', description: 'Show storage on a specific node' },
{ code: 'nfs', description: 'Find NFS storage' },
]}
footerHighlight="node:pve1 nfs"
footerText="Combine filters to zero in on exactly what you need."
triggerVariant="icon"
buttonLabel="Search tips"
openOnHover
/>
</div>
<Show when={isHistoryOpen()}>
<div
ref={(el) => (historyMenuRef = el)}
class="absolute left-0 right-0 top-full z-50 mt-2 w-full overflow-hidden rounded-lg border border-gray-200 bg-white text-sm shadow-xl dark:border-gray-700 dark:bg-gray-800"
role="listbox"
>
<Show
when={searchHistory().length > 0}
fallback={
<div class="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
Your recent storage searches will show here.
</div>
}
>
<div class="max-h-52 overflow-y-auto py-1">
<For each={searchHistory()}>
{(entry) => (
<div class="flex items-center justify-between px-2 py-1.5 hover:bg-blue-50 dark:hover:bg-blue-900/20">
<button
type="button"
class="flex-1 truncate pr-2 text-left text-sm text-gray-700 transition-colors hover:text-blue-600 focus:outline-none dark:text-gray-200 dark:hover:text-blue-300"
onClick={() => {
props.setSearch(entry);
commitSearchToHistory(entry);
setIsHistoryOpen(false);
focusSearchInput();
}}
onMouseDown={markSuppressCommit}
>
{entry}
</button>
<button
type="button"
class="ml-1 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 focus:outline-none dark:text-gray-500 dark:hover:bg-gray-700/70 dark:hover:text-gray-200"
title="Remove from history"
onClick={() => deleteHistoryEntry(entry)}
onMouseDown={markSuppressCommit}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
)}
</For>
</div>
<button
type="button"
class="flex w-full items-center justify-center gap-2 border-t border-gray-200 px-3 py-2 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700/80 dark:hover:text-gray-200"
onClick={clearHistory}
onMouseDown={markSuppressCommit}
>
Clear history
</button>
</Show>
</div>
</Show>
</div>
{/* Filters */}
<div class="flex flex-wrap items-center gap-2">
{/* Row 2: Filters - Compact horizontal layout */}
<div class="flex flex-wrap items-center gap-x-2 gap-y-2">
{/* Group By Filter */}
<Show when={props.groupBy && props.setGroupBy}>
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
@@ -355,17 +349,15 @@ export const StorageFilter: Component<StorageFilterProps> = (props) => {
Offline
</button>
</div>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Sort controls */}
<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Sort
</span>
<div class="flex items-center gap-1.5">
<select
value={props.sortKey()}
onChange={(e) => props.setSortKey(e.currentTarget.value)}
class="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400"
class="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500"
>
{sortOptions.map((option) => (
<option value={option.value}>{option.label}</option>
@@ -394,42 +386,38 @@ export const StorageFilter: Component<StorageFilterProps> = (props) => {
</svg>
</button>
</div>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Reset Button */}
<button
onClick={() => {
props.setSearch('');
props.setSortKey('name');
props.setSortDirection('asc');
if (props.setGroupBy) props.setGroupBy('node');
if (props.setStatusFilter) props.setStatusFilter('all');
}}
title="Reset all filters"
class={`flex items-center justify-center px-2.5 py-1 text-xs font-medium rounded-lg transition-colors ${props.search().trim() !== '' ||
props.sortKey() !== 'name' ||
props.sortDirection() !== 'asc' ||
(props.groupBy && props.groupBy!() !== 'node') ||
(props.statusFilter && props.statusFilter!() !== 'all')
? 'text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900/70'
: 'text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
{/* Reset Button - Only show when filters are active */}
<Show when={hasActiveFilters()}>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
<button
onClick={() => {
props.setSearch('');
props.setSortKey('name');
props.setSortDirection('asc');
if (props.setGroupBy) props.setGroupBy('node');
if (props.setStatusFilter) props.setStatusFilter('all');
}}
title="Reset all filters"
class="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium rounded-lg transition-colors
text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900/70"
>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M8 16H3v5" />
</svg>
<span class="ml-1 hidden sm:inline">Reset</span>
</button>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M8 16H3v5" />
</svg>
Reset
</button>
</Show>
</div>
</div>
</Card>