refactor: condense filter chips into single button with dropdown

Replaces the 7-chip row with a single "+ Filter" button that opens a
compact popover: dimension list → operator + search + values. Much
cleaner default state, same functionality.
This commit is contained in:
Usman Baig
2026-03-06 23:02:35 +01:00
parent 386b4a8c44
commit 7be30b57b5
2 changed files with 155 additions and 201 deletions

View File

@@ -460,7 +460,7 @@ export default function SiteDashboardPage() {
</div>
{/* Dimension Filters */}
<div className="space-y-2 mb-2">
<div className="flex items-center gap-2 flex-wrap mb-2">
<AddFilterDropdown onAdd={handleAddFilter} suggestions={filterSuggestions} />
<FilterBar filters={filters} onRemove={handleRemoveFilter} onClear={handleClearFilters} />
</div>

View File

@@ -18,38 +18,26 @@ interface AddFilterDropdownProps {
suggestions?: FilterSuggestions
}
// Which dimensions show as always-visible chips vs hidden in "More"
const PRIMARY_DIMS = ['page', 'referrer', 'country', 'browser', 'os', 'device']
const SECONDARY_DIMS = ['region', 'city', 'utm_source', 'utm_medium', 'utm_campaign']
const ALL_DIMS = ['page', 'referrer', 'country', 'region', 'city', 'browser', 'os', 'device', 'utm_source', 'utm_medium', 'utm_campaign']
function DimensionPopover({
dimension,
suggestions,
onApply,
onClose,
}: {
dimension: string
suggestions: FilterSuggestion[]
onApply: (filter: DimensionFilter) => void
onClose: () => void
}) {
export default function AddFilterDropdown({ onAdd, suggestions = {} }: AddFilterDropdownProps) {
const [isOpen, setIsOpen] = useState(false)
const [selectedDim, setSelectedDim] = useState<string | null>(null)
const [operator, setOperator] = useState<DimensionFilter['operator']>('is')
const [search, setSearch] = useState('')
const ref = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
// Close on outside click or Escape
useEffect(() => {
inputRef.current?.focus()
}, [])
useEffect(() => {
if (!isOpen) return
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose()
handleClose()
}
}
function handleEsc(e: KeyboardEvent) {
if (e.key === 'Escape') onClose()
if (e.key === 'Escape') handleClose()
}
document.addEventListener('mousedown', handleClick)
document.addEventListener('keydown', handleEsc)
@@ -57,197 +45,163 @@ function DimensionPopover({
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('keydown', handleEsc)
}
}, [onClose])
}, [isOpen])
const filtered = suggestions.filter(s =>
// Focus search input when a dimension is selected
useEffect(() => {
if (selectedDim) inputRef.current?.focus()
}, [selectedDim])
function handleClose() {
setIsOpen(false)
setSelectedDim(null)
setOperator('is')
setSearch('')
}
function handleSelectValue(value: string) {
onAdd({ dimension: selectedDim!, operator, values: [value] })
handleClose()
}
function handleSubmitCustom() {
if (!search.trim() || !selectedDim) return
onAdd({ dimension: selectedDim, operator, values: [search.trim()] })
handleClose()
}
const dimSuggestions = selectedDim ? (suggestions[selectedDim] || []) : []
const filtered = dimSuggestions.filter(s =>
s.label.toLowerCase().includes(search.toLowerCase()) ||
s.value.toLowerCase().includes(search.toLowerCase())
)
function handleSelectValue(value: string) {
onApply({ dimension, operator, values: [value] })
onClose()
}
function handleSubmitCustom() {
if (!search.trim()) return
onApply({ dimension, operator, values: [search.trim()] })
onClose()
}
return (
<div
ref={ref}
className="absolute top-full left-0 mt-1.5 z-50 w-72 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden"
>
{/* Operator pills */}
<div className="flex gap-1 px-3 pt-3 pb-2 flex-wrap">
{OPERATORS.map(op => (
<button
key={op}
onClick={() => setOperator(op)}
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors cursor-pointer ${
operator === op
? 'bg-brand-orange text-white'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700'
}`}
>
{OPERATOR_LABELS[op]}
</button>
))}
</div>
<div className="relative" ref={ref}>
<button
onClick={() => {
if (isOpen) { handleClose() } else { setIsOpen(true) }
}}
className={`inline-flex items-center gap-2 px-3 py-1.5 text-xs font-medium rounded-lg transition-all cursor-pointer ${
isOpen
? 'bg-brand-orange/10 text-brand-orange border border-brand-orange/30'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700 hover:text-neutral-900 dark:hover:text-white border border-transparent'
}`}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
Filter
</button>
{/* Search input */}
<div className="px-3 pb-2">
<input
ref={inputRef}
value={search}
onChange={e => setSearch(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
if (filtered.length === 1) {
handleSelectValue(filtered[0].value)
} else {
handleSubmitCustom()
}
}
}}
placeholder={`Search or type ${DIMENSION_LABELS[dimension]?.toLowerCase()}...`}
className="w-full px-3 py-2 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange/40 focus:border-brand-orange transition-colors"
/>
</div>
{/* Suggestions list */}
{filtered.length > 0 && (
<div className="max-h-52 overflow-y-auto border-t border-neutral-100 dark:border-neutral-800">
{filtered.map(s => (
<button
key={s.value}
onClick={() => handleSelectValue(s.value)}
className="w-full flex items-center justify-between px-3 py-2 text-sm text-left hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors cursor-pointer"
>
<span className="truncate text-neutral-900 dark:text-white">{s.label}</span>
{s.count !== undefined && (
<span className="text-xs text-neutral-400 dark:text-neutral-500 ml-2 tabular-nums flex-shrink-0">
{s.count.toLocaleString()}
{isOpen && (
<div className="absolute top-full left-0 mt-1.5 z-50 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden min-w-[280px]">
{!selectedDim ? (
/* Step 1: Dimension list */
<div className="py-1">
{ALL_DIMS.map(dim => (
<button
key={dim}
onClick={() => setSelectedDim(dim)}
className="w-full flex items-center justify-between px-4 py-2.5 text-sm text-left hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors cursor-pointer"
>
<span className="text-neutral-900 dark:text-white font-medium">{DIMENSION_LABELS[dim]}</span>
<svg className="w-3.5 h-3.5 text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
))}
</div>
) : (
/* Step 2: Operator + search + values */
<>
{/* Header with back button */}
<div className="flex items-center gap-2 px-3 pt-3 pb-2">
<button
onClick={() => { setSelectedDim(null); setSearch(''); setOperator('is') }}
className="p-1 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors cursor-pointer rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-800"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="text-sm font-semibold text-neutral-900 dark:text-white">
{DIMENSION_LABELS[selectedDim]}
</span>
</div>
{/* Operator pills */}
<div className="flex gap-1 px-3 pb-2 flex-wrap">
{OPERATORS.map(op => (
<button
key={op}
onClick={() => setOperator(op)}
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors cursor-pointer ${
operator === op
? 'bg-brand-orange text-white'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700'
}`}
>
{OPERATOR_LABELS[op]}
</button>
))}
</div>
{/* Search input */}
<div className="px-3 pb-2">
<input
ref={inputRef}
value={search}
onChange={e => setSearch(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
if (filtered.length === 1) {
handleSelectValue(filtered[0].value)
} else {
handleSubmitCustom()
}
}
}}
placeholder={`Search ${DIMENSION_LABELS[selectedDim]?.toLowerCase()}...`}
className="w-full px-3 py-2 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange/40 focus:border-brand-orange transition-colors"
/>
</div>
{/* Values list */}
{filtered.length > 0 && (
<div className="max-h-52 overflow-y-auto border-t border-neutral-100 dark:border-neutral-800">
{filtered.map(s => (
<button
key={s.value}
onClick={() => handleSelectValue(s.value)}
className="w-full flex items-center justify-between px-4 py-2 text-sm text-left hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors cursor-pointer"
>
<span className="truncate text-neutral-900 dark:text-white">{s.label}</span>
{s.count !== undefined && (
<span className="text-xs text-neutral-400 dark:text-neutral-500 ml-2 tabular-nums flex-shrink-0">
{s.count.toLocaleString()}
</span>
)}
</button>
))}
</div>
)}
</button>
))}
</div>
)}
{/* Custom value apply when no matches */}
{search.trim() && filtered.length === 0 && (
<div className="px-3 py-3 border-t border-neutral-100 dark:border-neutral-800">
<button
onClick={handleSubmitCustom}
className="w-full px-3 py-2 text-sm font-medium bg-brand-orange text-white rounded-lg hover:bg-brand-orange/90 transition-colors cursor-pointer"
>
Filter by &ldquo;{search.trim()}&rdquo;
</button>
{/* Custom value apply */}
{search.trim() && filtered.length === 0 && (
<div className="px-3 py-3 border-t border-neutral-100 dark:border-neutral-800">
<button
onClick={handleSubmitCustom}
className="w-full px-3 py-2 text-sm font-medium bg-brand-orange text-white rounded-lg hover:bg-brand-orange/90 transition-colors cursor-pointer"
>
Filter by &ldquo;{search.trim()}&rdquo;
</button>
</div>
)}
</>
)}
</div>
)}
</div>
)
}
export default function AddFilterDropdown({ onAdd, suggestions = {} }: AddFilterDropdownProps) {
const [openDim, setOpenDim] = useState<string | null>(null)
const [showMore, setShowMore] = useState(false)
const moreRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!showMore) return
function handleClick(e: MouseEvent) {
if (moreRef.current && !moreRef.current.contains(e.target as Node)) {
setShowMore(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [showMore])
function renderChip(dim: string) {
const isOpen = openDim === dim
return (
<div key={dim} className="relative">
<button
onClick={() => {
setOpenDim(isOpen ? null : dim)
setShowMore(false)
}}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-all cursor-pointer ${
isOpen
? 'bg-brand-orange/10 text-brand-orange border border-brand-orange/30'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700 hover:text-neutral-900 dark:hover:text-white border border-transparent'
}`}
>
{DIMENSION_LABELS[dim]}
<svg className={`w-3 h-3 transition-transform ${isOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<DimensionPopover
dimension={dim}
suggestions={suggestions[dim] || []}
onApply={onAdd}
onClose={() => setOpenDim(null)}
/>
)}
</div>
)
}
return (
<div className="flex items-center gap-1.5 flex-wrap">
{PRIMARY_DIMS.map(renderChip)}
{/* More dropdown for secondary dimensions */}
<div className="relative" ref={moreRef}>
<button
onClick={() => {
setShowMore(!showMore)
setOpenDim(null)
}}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-all cursor-pointer ${
showMore || SECONDARY_DIMS.includes(openDim || '')
? 'bg-brand-orange/10 text-brand-orange border border-brand-orange/30'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700 hover:text-neutral-900 dark:hover:text-white border border-transparent'
}`}
>
More
<svg className={`w-3 h-3 transition-transform ${showMore ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{showMore && (
<div className="absolute top-full left-0 mt-1.5 z-50 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden py-1 min-w-[160px]">
{SECONDARY_DIMS.map(dim => (
<button
key={dim}
onClick={() => {
setShowMore(false)
setOpenDim(dim)
}}
className="w-full text-left px-4 py-2 text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors cursor-pointer"
>
{DIMENSION_LABELS[dim]}
</button>
))}
</div>
)}
{/* Render popover for secondary dims inline here */}
{openDim && SECONDARY_DIMS.includes(openDim) && (
<DimensionPopover
dimension={openDim}
suggestions={suggestions[openDim] || []}
onApply={onAdd}
onClose={() => setOpenDim(null)}
/>
)}
</div>
</div>
)
}
}