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 }) {
|
function ToolbarDropdown({ item }: { item: ToolbarItem }) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const ref = React.useRef<HTMLDivElement>(null);
|
const ref = React.useRef<HTMLDivElement>(null);
|
||||||
|
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const [menuPos, setMenuPos] = React.useState({ top: 0, left: 0 });
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const handler = (e: MouseEvent) => {
|
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);
|
document.addEventListener('mousedown', handler);
|
||||||
return () => document.removeEventListener('mousedown', handler);
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
}, [open]);
|
}, [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);
|
const activeOption = item.menuOptions?.find((o) => o.active);
|
||||||
|
let lastSection: string | undefined = undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="relative flex-shrink-0">
|
<>
|
||||||
<button
|
<div ref={ref} className="relative flex-shrink-0">
|
||||||
onClick={() => setOpen(!open)}
|
<button
|
||||||
title={item.label}
|
onClick={handleToggle}
|
||||||
aria-label={item.label}
|
title={item.label}
|
||||||
className={`
|
aria-label={item.label}
|
||||||
inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium
|
className={`
|
||||||
transition-colors duration-100 cursor-pointer
|
inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium
|
||||||
${open || activeOption ? 'bg-primary-100 text-primary-700' : 'text-secondary-600 hover:bg-secondary-100 hover:text-secondary-900'}
|
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>
|
{item.icon && <span className="w-4 h-4 flex-shrink-0">{item.icon}</span>}
|
||||||
<svg className={`w-3 h-3 transition-transform ${open ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
{!item.iconOnly && <span className="hidden sm:inline">{activeOption?.label || item.label}</span>}
|
||||||
<polyline points="6 9 12 15 18 9" />
|
<svg className={`w-3 h-3 transition-transform ${open ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||||
</svg>
|
<polyline points="6 9 12 15 18 9" />
|
||||||
</button>
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
className="absolute top-full left-0 mt-1 bg-white border border-secondary-200 rounded-lg shadow-lg z-[100] py-1"
|
ref={menuRef}
|
||||||
style={{ minWidth: item.menuWidth || '180px' }}
|
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) => (
|
{item.menuOptions?.map((opt) => {
|
||||||
<button
|
const showSection = opt.section && opt.section !== lastSection;
|
||||||
key={opt.value}
|
if (showSection) lastSection = opt.section;
|
||||||
onClick={() => {
|
return (
|
||||||
opt.onClick();
|
<React.Fragment key={opt.value}>
|
||||||
setOpen(false);
|
{showSection && (
|
||||||
}}
|
<div className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-secondary-400">
|
||||||
className={`
|
{opt.section}
|
||||||
w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left transition-colors
|
</div>
|
||||||
${opt.active ? 'bg-primary-50 text-primary-700 font-medium' : 'text-secondary-700 hover:bg-secondary-100'}
|
)}
|
||||||
`}
|
{opt.separator && <div className="my-1 border-t border-secondary-100" />}
|
||||||
>
|
<button
|
||||||
{opt.icon && <span className="w-4 h-4 flex-shrink-0">{opt.icon}</span>}
|
onClick={() => {
|
||||||
<span className="flex-1">{opt.label}</span>
|
opt.onClick();
|
||||||
{opt.active && (
|
setOpen(false);
|
||||||
<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" />
|
disabled={opt.disabled}
|
||||||
</svg>
|
className={`
|
||||||
)}
|
w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left transition-colors
|
||||||
</button>
|
${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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -209,43 +209,35 @@ export function ContactsListPage() {
|
|||||||
onSearch: handleSearch,
|
onSearch: handleSearch,
|
||||||
onClick: () => {},
|
onClick: () => {},
|
||||||
},
|
},
|
||||||
// Sort by
|
// Unified filter dropdown — filter + sort in one panel
|
||||||
{
|
{
|
||||||
id: 'sort-by',
|
id: 'filter-sort',
|
||||||
plugin: 'contacts',
|
|
||||||
label: t('common.sort'),
|
|
||||||
type: 'select' as const,
|
|
||||||
group: 'sort',
|
|
||||||
selectOptions: sortOptions,
|
|
||||||
selectValue: sortBy,
|
|
||||||
onSelect: (val: string) => { setSortBy(val); setPage(1); },
|
|
||||||
onClick: () => {},
|
|
||||||
},
|
|
||||||
// Sort order toggle
|
|
||||||
{
|
|
||||||
id: 'sort-order',
|
|
||||||
plugin: 'contacts',
|
|
||||||
label: sortOrder === 'asc' ? t('common.sortDesc') : t('common.sortAsc'),
|
|
||||||
type: 'button' as const,
|
|
||||||
group: 'sort',
|
|
||||||
icon: sortOrder === 'asc'
|
|
||||||
? <ArrowDownAZ className="w-3.5 h-3.5" strokeWidth={2} />
|
|
||||||
: <ArrowUpZA className="w-3.5 h-3.5" strokeWidth={2} />,
|
|
||||||
onClick: () => handleSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc'),
|
|
||||||
},
|
|
||||||
// Type filter dropdown (all / companies / persons)
|
|
||||||
{
|
|
||||||
id: 'filter-type',
|
|
||||||
plugin: 'contacts',
|
plugin: 'contacts',
|
||||||
label: t('common.filter', 'Filter'),
|
label: t('common.filter', 'Filter'),
|
||||||
type: 'dropdown' as const,
|
type: 'dropdown' as const,
|
||||||
group: 'filter',
|
group: 'filter',
|
||||||
icon: <Filter className="w-3.5 h-3.5" strokeWidth={2} />,
|
icon: <Filter className="w-3.5 h-3.5" strokeWidth={2} />,
|
||||||
menuWidth: '200px',
|
iconOnly: true,
|
||||||
|
menuWidth: '240px',
|
||||||
menuOptions: [
|
menuOptions: [
|
||||||
{ value: 'all', label: t('common.all'), active: selectedFilter === 'all', onClick: () => handleSelectFilter('all') },
|
// Section: Filter
|
||||||
|
{ value: 'all', label: t('common.all'), section: t('common.filter', 'Filter'), active: selectedFilter === 'all', onClick: () => handleSelectFilter('all') },
|
||||||
{ value: 'company', label: t('contacts.companies'), active: selectedFilter === 'company', onClick: () => handleSelectFilter('company') },
|
{ value: 'company', label: t('contacts.companies'), active: selectedFilter === 'company', onClick: () => handleSelectFilter('company') },
|
||||||
{ value: 'person', label: t('contacts.persons'), active: selectedFilter === 'person', onClick: () => handleSelectFilter('person') },
|
{ value: 'person', label: t('contacts.persons'), active: selectedFilter === 'person', onClick: () => handleSelectFilter('person') },
|
||||||
|
// Separator
|
||||||
|
{ value: 'sep-sort', label: '', separator: true, onClick: () => {} },
|
||||||
|
// Section: Sort by
|
||||||
|
...sortOptions.map((opt) => ({
|
||||||
|
value: `sort-${opt.value}`, label: opt.label, section: t('common.sortBy', 'Sortieren nach'),
|
||||||
|
active: sortBy === opt.value, onClick: () => { setSortBy(opt.value); setPage(1); },
|
||||||
|
})),
|
||||||
|
// Separator
|
||||||
|
{ value: 'sep-order', label: '', separator: true, onClick: () => {} },
|
||||||
|
// Section: Sort order
|
||||||
|
{ value: 'sort-asc', label: t('common.sortAsc', 'Aufsteigend'), section: t('common.sortOrder', 'Sortierreihenfolge'),
|
||||||
|
icon: <ArrowDownAZ className="w-3.5 h-3.5" strokeWidth={2} />, active: sortOrder === 'asc', onClick: () => handleSortChange(sortBy, 'asc') },
|
||||||
|
{ value: 'sort-desc', label: t('common.sortDesc', 'Absteigend'),
|
||||||
|
icon: <ArrowUpZA className="w-3.5 h-3.5" strokeWidth={2} />, active: sortOrder === 'desc', onClick: () => handleSortChange(sortBy, 'desc') },
|
||||||
],
|
],
|
||||||
onClick: () => {},
|
onClick: () => {},
|
||||||
},
|
},
|
||||||
@@ -256,7 +248,7 @@ export function ContactsListPage() {
|
|||||||
label: t('common.view', 'Ansicht'),
|
label: t('common.view', 'Ansicht'),
|
||||||
type: 'dropdown' as const,
|
type: 'dropdown' as const,
|
||||||
group: 'view',
|
group: 'view',
|
||||||
icon: <List className="w-3.5 h-3.5" strokeWidth={2} />,
|
icon: <LayoutGrid className="w-3.5 h-3.5" strokeWidth={2} />,
|
||||||
menuWidth: '180px',
|
menuWidth: '180px',
|
||||||
menuOptions: [
|
menuOptions: [
|
||||||
{ value: 'list', label: t('contacts.viewList'), icon: <List className="w-3.5 h-3.5" strokeWidth={2} />, active: viewMode === 'list', onClick: () => setViewMode('list') },
|
{ value: 'list', label: t('contacts.viewList'), icon: <List className="w-3.5 h-3.5" strokeWidth={2} />, active: viewMode === 'list', onClick: () => setViewMode('list') },
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ export interface ToolbarItem {
|
|||||||
selectOptions?: { value: string; label: string }[];
|
selectOptions?: { value: string; label: string }[];
|
||||||
selectValue?: string;
|
selectValue?: string;
|
||||||
onSelect?: (value: string) => void;
|
onSelect?: (value: string) => void;
|
||||||
menuOptions?: { value: string; label: string; icon?: React.ReactNode; active?: boolean; onClick: () => void }[];
|
menuOptions?: { value: string; label: string; icon?: React.ReactNode; active?: boolean; onClick: () => void; section?: string; separator?: boolean; disabled?: boolean }[];
|
||||||
menuWidth?: string;
|
menuWidth?: string;
|
||||||
|
iconOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PluginToolbarState {
|
interface PluginToolbarState {
|
||||||
|
|||||||
Reference in New Issue
Block a user