feat: add MultiSelectDropdown component and CRUD styles
This commit is contained in:
120
ui/src/pages/admin/rbac/components/MultiSelectDropdown.tsx
Normal file
120
ui/src/pages/admin/rbac/components/MultiSelectDropdown.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import styles from '../RbacPage.module.css';
|
||||
|
||||
interface MultiSelectItem {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface MultiSelectDropdownProps {
|
||||
items: MultiSelectItem[];
|
||||
onApply: (selectedIds: string[]) => void;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function MultiSelectDropdown({ items, onApply, placeholder = 'Search...', label = '+ Add' }: MultiSelectDropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
setSelected(new Set());
|
||||
}
|
||||
}
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
setSelected(new Set());
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const filtered = items.filter(item =>
|
||||
item.label.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
function toggle(id: string) {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
onApply(Array.from(selected));
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
setSelected(new Set());
|
||||
}
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.multiSelectWrapper} ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.addChip}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{open && (
|
||||
<div className={styles.dropdown}>
|
||||
<div className={styles.dropdownSearch}>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.dropdownSearchInput}
|
||||
placeholder={placeholder}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.dropdownList}>
|
||||
{filtered.length === 0 ? (
|
||||
<div className={styles.dropdownEmpty}>No items found</div>
|
||||
) : (
|
||||
filtered.map(item => (
|
||||
<label key={item.id} className={styles.dropdownItem}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={styles.dropdownItemCheckbox}
|
||||
checked={selected.has(item.id)}
|
||||
onChange={() => toggle(item.id)}
|
||||
/>
|
||||
{item.label}
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.dropdownFooter}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dropdownApply}
|
||||
disabled={selected.size === 0}
|
||||
onClick={handleApply}
|
||||
>
|
||||
Apply{selected.size > 0 ? ` (${selected.size})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user