fix: portal all filter popovers to prevent occlusion by sticky columns

This commit is contained in:
Christian Vidal Wolf
2026-04-24 17:13:12 +02:00
parent e96d245f59
commit d06285ddaa
2 changed files with 82 additions and 34 deletions
+66 -24
View File
@@ -1,4 +1,5 @@
import React, { useState, useMemo, useRef, useCallback, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { ExcelRow } from '../types';
import { useColumns } from '../contexts/ColumnsContext';
import {
@@ -1149,7 +1150,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
</div>
) : (
<table ref={tableRef} className="w-full text-sm border-collapse table-fixed">
<thead className="sticky top-0 z-10 bg-slate-900 border-b border-slate-700">
<thead className="sticky top-0 z-[100] bg-slate-900 border-b border-slate-700">
<tr>
<th style={{
width: columnWidths.articleNo,
@@ -1166,9 +1167,10 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
Article No <SortIcon current={sortConfig.key === 'articleNo' ? sortConfig.direction : null} />
</span>
<button
id="filter-trigger-sku"
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === 'sku' ? null : 'sku'); }}
className={cn(
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0',
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0 filter-trigger-btn',
articleNoColFilter.terms.some(t => t.trim() !== '') ? 'text-blue-400 bg-blue-400/10' : 'text-slate-500 opacity-0 group-hover:opacity-100'
)}
>
@@ -1177,6 +1179,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
</div>
{openFilter === 'sku' && (
<TextFilterPopover
triggerId="filter-trigger-sku"
value={articleNoColFilter}
onChange={setArticleNoColFilter}
onClose={() => setOpenFilter(null)}
@@ -1199,9 +1202,10 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
{isPinned('articleName') && <Pin className="w-3 h-3 text-blue-400 shrink-0" />}
Article Name <SortIcon current={sortConfig.key === 'articleName' ? sortConfig.direction : null} /></span>
<button
id="filter-trigger-name"
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === 'name' ? null : 'name'); }}
className={cn(
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0',
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0 filter-trigger-btn',
nameColFilter.terms.some(t => t.trim() !== '') ? 'text-blue-400 bg-blue-400/10' : 'text-slate-500 opacity-0 group-hover:opacity-100'
)}
>
@@ -1210,6 +1214,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
</div>
{openFilter === 'name' && (
<TextFilterPopover
triggerId="filter-trigger-name"
value={nameColFilter}
onChange={setNameColFilter}
onClose={() => setOpenFilter(null)}
@@ -1232,9 +1237,10 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
{isPinned('line') && <Pin className="w-3 h-3 text-blue-400 shrink-0" />}
Line <SortIcon current={sortConfig.key === 'line' ? sortConfig.direction : null} /></span>
<button
id="filter-trigger-line"
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === 'line' ? null : 'line'); }}
className={cn(
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0',
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0 filter-trigger-btn',
lineMultiFilter.length > 0 ? 'text-blue-400 bg-blue-400/10' : 'text-slate-500 opacity-0 group-hover:opacity-100'
)}
>
@@ -1243,6 +1249,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
</div>
{openFilter === 'line' && (
<ColumnFilterPopover
triggerId="filter-trigger-line"
uniqueValues={uniqueLines}
selectedValues={lineMultiFilter}
onToggle={val => setLineMultiFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
@@ -1268,9 +1275,10 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
{isPinned('classification') && <Pin className="w-3 h-3 text-blue-400 shrink-0" />}
Classification <SortIcon current={sortConfig.key === 'classification' ? sortConfig.direction : null} /></span>
<button
id="filter-trigger-class"
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === 'classification' ? null : 'classification'); }}
className={cn(
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0',
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0 filter-trigger-btn',
classificationFilter.length > 0 ? 'text-blue-400 bg-blue-400/10' : 'text-slate-500 opacity-0 group-hover:opacity-100'
)}
>
@@ -1279,6 +1287,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
</div>
{openFilter === 'classification' && (
<ColumnFilterPopover
triggerId="filter-trigger-class"
uniqueValues={uniqueClassifications}
selectedValues={classificationFilter}
onToggle={val => setClassificationFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
@@ -1299,14 +1308,16 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
{isPinned('productType') && <Pin className="w-3 h-3 text-blue-400 shrink-0" />}
Type <SortIcon current={sortConfig.key === 'productType' ? sortConfig.direction : null} />
<button
id="filter-trigger-type"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); setOpenFilter(openFilter === 'productType' ? null : 'productType'); }}
className={cn('ml-1 p-0.5 rounded transition-colors', productTypeFilter.length > 0 ? 'text-purple-400 bg-purple-400/10' : 'text-slate-500 opacity-0 group-hover:opacity-100')}
className={cn('ml-1 p-0.5 rounded transition-colors filter-trigger-btn', productTypeFilter.length > 0 ? 'text-purple-400 bg-purple-400/10' : 'text-slate-500 opacity-0 group-hover:opacity-100')}
>
<Filter className="w-3 h-3" />
</button>
</span>
{openFilter === 'productType' && (
<ColumnFilterPopover
triggerId="filter-trigger-type"
uniqueValues={uniqueProductTypes}
selectedValues={productTypeFilter}
onToggle={(val: string) => setProductTypeFilter((prev: string[]) => prev.includes(val) ? prev.filter((v: string) => v !== val) : [...prev, val])}
@@ -1352,21 +1363,23 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
{isPinned(colKey) && <Pin className="w-3 h-3 text-blue-400 shrink-0" />}
{col.name} <SortIcon current={sortConfig.key === col.index ? sortConfig.direction : null} /></span>
<button
id={`filter-trigger-${colKey}`}
onClick={(e) => {
e.stopPropagation();
setOpenFilter(openFilter === `prc_${col.index}` ? null : `prc_${col.index}`);
setOpenFilter(openFilter === colKey ? null : colKey);
}}
className={cn(
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0',
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0 filter-trigger-btn',
dynamicColFilters[col.index]?.length > 0 ? 'text-blue-400 bg-blue-400/10' : 'text-slate-500 opacity-0 group-hover:opacity-100'
)}
>
<Filter className="w-3 h-3" />
</button>
</div>
{openFilter === `prc_${col.index}` && (
<ColumnFilterPopover
uniqueValues={dynamicColUniqueValues[col.index] || []}
{openFilter === colKey && (
<ColumnFilterPopover
triggerId={`filter-trigger-${colKey}`}
uniqueValues={dynamicColUniqueValues[col.index] || []}
selectedValues={dynamicColFilters[col.index] || []}
onToggle={val => setDynamicColFilters(prev => {
const current = prev[col.index] || [];
@@ -1399,9 +1412,10 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
{isPinned('unitsOuter') && <Pin className="w-3 h-3 text-blue-400 shrink-0" />}
Units/Outer <SortIcon current={sortConfig.key === 'unitsOuter' ? sortConfig.direction : null} /></span>
<button
id="filter-trigger-unitsOuter"
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === 'unitsOuter' ? null : 'unitsOuter'); }}
className={cn(
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0',
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0 filter-trigger-btn',
unitsOuterFilter.length > 0 ? 'text-blue-400 bg-blue-400/10' : 'text-slate-500 opacity-0 group-hover:opacity-100'
)}
>
@@ -1410,6 +1424,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
</div>
{openFilter === 'unitsOuter' && (
<ColumnFilterPopover
triggerId="filter-trigger-unitsOuter"
uniqueValues={uniqueUnitsOuter}
selectedValues={unitsOuterFilter}
onToggle={val => setUnitsOuterFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
@@ -1436,9 +1451,10 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
{isPinned('outerW') && <Pin className="w-3 h-3 text-blue-400 shrink-0" />}
Outer W <SortIcon current={sortConfig.key === 'outerW' ? sortConfig.direction : null} /></span>
<button
id="filter-trigger-outerW"
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === 'outerW' ? null : 'outerW'); }}
className={cn(
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0',
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0 filter-trigger-btn',
outerWFilter.length > 0 ? 'text-blue-400 bg-blue-400/10' : 'text-slate-500 opacity-0 group-hover:opacity-100'
)}
>
@@ -1447,6 +1463,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
</div>
{openFilter === 'outerW' && (
<ColumnFilterPopover
triggerId="filter-trigger-outerW"
uniqueValues={uniqueOuterW}
selectedValues={outerWFilter}
onToggle={val => setOuterWFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
@@ -1473,9 +1490,10 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
{isPinned('outerL') && <Pin className="w-3 h-3 text-blue-400 shrink-0" />}
Outer L <SortIcon current={sortConfig.key === 'outerL' ? sortConfig.direction : null} /></span>
<button
id="filter-trigger-outerL"
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === 'outerL' ? null : 'outerL'); }}
className={cn(
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0',
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0 filter-trigger-btn',
outerLFilter.length > 0 ? 'text-blue-400 bg-blue-400/10' : 'text-slate-500 opacity-0 group-hover:opacity-100'
)}
>
@@ -1484,6 +1502,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
</div>
{openFilter === 'outerL' && (
<ColumnFilterPopover
triggerId="filter-trigger-outerL"
uniqueValues={uniqueOuterL}
selectedValues={outerLFilter}
onToggle={val => setOuterLFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
@@ -1510,9 +1529,10 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
{isPinned('outerH') && <Pin className="w-3 h-3 text-blue-400 shrink-0" />}
Outer H <SortIcon current={sortConfig.key === 'outerH' ? sortConfig.direction : null} /></span>
<button
id="filter-trigger-outerH"
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === 'outerH' ? null : 'outerH'); }}
className={cn(
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0',
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0 filter-trigger-btn',
outerHFilter.length > 0 ? 'text-blue-400 bg-blue-400/10' : 'text-slate-500 opacity-0 group-hover:opacity-100'
)}
>
@@ -1521,6 +1541,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
</div>
{openFilter === 'outerH' && (
<ColumnFilterPopover
triggerId="filter-trigger-outerH"
uniqueValues={uniqueOuterH}
selectedValues={outerHFilter}
onToggle={val => setOuterHFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
@@ -1551,21 +1572,23 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
{isPinned(colKey) && <Pin className="w-3 h-3 text-blue-400 shrink-0" />}
{col.name} <SortIcon current={sortConfig.key === col.index ? sortConfig.direction : null} /></span>
<button
id={`filter-trigger-${colKey}`}
onClick={(e) => {
e.stopPropagation();
setOpenFilter(openFilter === `con_${col.index}` ? null : `con_${col.index}`);
setOpenFilter(openFilter === colKey ? null : colKey);
}}
className={cn(
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0',
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0 filter-trigger-btn',
dynamicColFilters[col.index]?.length > 0 ? 'text-blue-400 bg-blue-400/10' : 'text-slate-500 opacity-0 group-hover:opacity-100'
)}
>
<Filter className="w-3 h-3" />
</button>
</div>
{openFilter === `con_${col.index}` && (
<ColumnFilterPopover
uniqueValues={dynamicColUniqueValues[col.index] || []}
{openFilter === colKey && (
<ColumnFilterPopover
triggerId={`filter-trigger-${colKey}`}
uniqueValues={dynamicColUniqueValues[col.index] || []}
selectedValues={dynamicColFilters[col.index] || []}
onToggle={val => setDynamicColFilters(prev => {
const current = prev[col.index] || [];
@@ -2002,12 +2025,24 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
}
// ── Text filter popover ───────────────────────────────────────────────────────
function TextFilterPopover({ value, onChange, onClose, zIndex = 50 }: {
function TextFilterPopover({ value, onChange, onClose, triggerId }: {
value: { terms: string[]; op: 'and' | 'or' };
onChange: (v: { terms: string[]; op: 'and' | 'or' }) => void;
onClose: () => void;
zIndex?: number;
triggerId: string;
}) {
const [position, setPosition] = useState({ top: 0, left: 0 });
useEffect(() => {
const trigger = document.getElementById(triggerId);
if (trigger) {
const rect = trigger.getBoundingClientRect();
setPosition({
top: rect.bottom + window.scrollY + 4,
left: Math.min(rect.left + window.scrollX, window.innerWidth - 300)
});
}
}, [triggerId]);
const addTerm = () => {
if (value.terms.length < 5) {
onChange({ ...value, terms: [...value.terms, ''] });
@@ -2030,8 +2065,15 @@ function TextFilterPopover({ value, onChange, onClose, zIndex = 50 }: {
onChange({ ...value, terms: next });
};
return (
<div className="absolute top-full left-0 mt-1 w-72 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl p-4 flex flex-col gap-4 animate-in fade-in zoom-in-95 duration-100" style={{ zIndex }} onClick={e => e.stopPropagation()}>
return createPortal(
<div
className="fixed z-[9999] w-72 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl p-4 flex flex-col gap-4 animate-in fade-in zoom-in-95 duration-100"
style={{
top: position.top - window.scrollY,
left: position.left - window.scrollX
}}
onClick={e => e.stopPropagation()}
>
<div className="flex flex-col gap-3">
<label className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">Show rows where field contains:</label>