toolbar: unified filter+sort dropdown with sections, fixed positioning, iconOnly support
This commit is contained in:
@@ -88,65 +88,99 @@ function ToolbarSearch({ item }: { item: ToolbarItem }) {
|
||||
function ToolbarDropdown({ item }: { item: ToolbarItem }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
const [menuPos, setMenuPos] = React.useState({ top: 0, left: 0 });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
const target = e.target as Node;
|
||||
if (ref.current?.contains(target) || menuRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (!open && ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
setMenuPos({ top: rect.bottom + 4, left: rect.left });
|
||||
}
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
const activeOption = item.menuOptions?.find((o) => o.active);
|
||||
let lastSection: string | undefined = undefined;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
title={item.label}
|
||||
aria-label={item.label}
|
||||
className={`
|
||||
inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium
|
||||
transition-colors duration-100 cursor-pointer
|
||||
${open || activeOption ? 'bg-primary-100 text-primary-700' : 'text-secondary-600 hover:bg-secondary-100 hover:text-secondary-900'}
|
||||
`}
|
||||
>
|
||||
{item.icon && <span className="w-4 h-4 flex-shrink-0">{item.icon}</span>}
|
||||
<span className="hidden sm:inline">{activeOption?.label || item.label}</span>
|
||||
<svg className={`w-3 h-3 transition-transform ${open ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
<>
|
||||
<div ref={ref} className="relative flex-shrink-0">
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
title={item.label}
|
||||
aria-label={item.label}
|
||||
className={`
|
||||
inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium
|
||||
transition-colors duration-100 cursor-pointer
|
||||
${open || activeOption ? 'bg-primary-100 text-primary-700' : 'text-secondary-600 hover:bg-secondary-100 hover:text-secondary-900'}
|
||||
`}
|
||||
>
|
||||
{item.icon && <span className="w-4 h-4 flex-shrink-0">{item.icon}</span>}
|
||||
{!item.iconOnly && <span className="hidden sm:inline">{activeOption?.label || item.label}</span>}
|
||||
<svg className={`w-3 h-3 transition-transform ${open ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<div
|
||||
className="absolute top-full left-0 mt-1 bg-white border border-secondary-200 rounded-lg shadow-lg z-[100] py-1"
|
||||
style={{ minWidth: item.menuWidth || '180px' }}
|
||||
ref={menuRef}
|
||||
className="fixed bg-white border border-secondary-200 rounded-lg shadow-lg py-1"
|
||||
style={{
|
||||
top: menuPos.top,
|
||||
left: menuPos.left,
|
||||
minWidth: item.menuWidth || '180px',
|
||||
zIndex: 3000,
|
||||
}}
|
||||
>
|
||||
{item.menuOptions?.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => {
|
||||
opt.onClick();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`
|
||||
w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left transition-colors
|
||||
${opt.active ? 'bg-primary-50 text-primary-700 font-medium' : 'text-secondary-700 hover:bg-secondary-100'}
|
||||
`}
|
||||
>
|
||||
{opt.icon && <span className="w-4 h-4 flex-shrink-0">{opt.icon}</span>}
|
||||
<span className="flex-1">{opt.label}</span>
|
||||
{opt.active && (
|
||||
<svg className="w-3.5 h-3.5 text-primary-600" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{item.menuOptions?.map((opt) => {
|
||||
const showSection = opt.section && opt.section !== lastSection;
|
||||
if (showSection) lastSection = opt.section;
|
||||
return (
|
||||
<React.Fragment key={opt.value}>
|
||||
{showSection && (
|
||||
<div className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-secondary-400">
|
||||
{opt.section}
|
||||
</div>
|
||||
)}
|
||||
{opt.separator && <div className="my-1 border-t border-secondary-100" />}
|
||||
<button
|
||||
onClick={() => {
|
||||
opt.onClick();
|
||||
setOpen(false);
|
||||
}}
|
||||
disabled={opt.disabled}
|
||||
className={`
|
||||
w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left transition-colors
|
||||
${opt.active ? 'bg-primary-50 text-primary-700 font-medium' : 'text-secondary-700 hover:bg-secondary-100'}
|
||||
${opt.disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}
|
||||
`}
|
||||
>
|
||||
{opt.icon && <span className="w-4 h-4 flex-shrink-0">{opt.icon}</span>}
|
||||
<span className="flex-1">{opt.label}</span>
|
||||
{opt.active && (
|
||||
<svg className="w-3.5 h-3.5 text-primary-600" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user