feat: add MultiSelectDropdown component and CRUD styles
This commit is contained in:
@@ -568,3 +568,277 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Multi-Select Dropdown ─── */
|
||||||
|
.multiSelectWrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addChip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s, color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addChip:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
z-index: 10;
|
||||||
|
min-width: 220px;
|
||||||
|
max-height: 300px;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownSearch {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownSearchInput {
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownSearchInput:focus {
|
||||||
|
border-color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownList {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownItem:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownItemCheckbox {
|
||||||
|
accent-color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownFooter {
|
||||||
|
padding: 8px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownApply {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--amber);
|
||||||
|
color: #000;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownApply:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownEmpty {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Remove button on chips ─── */
|
||||||
|
.chipRemove {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.4;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 2px;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: opacity 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipRemove:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipRemove:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Delete button ─── */
|
||||||
|
.btnDelete {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid var(--rose);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--rose);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnDelete:hover {
|
||||||
|
background: rgba(244, 63, 94, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnDelete:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Inline Create Form ─── */
|
||||||
|
.createForm {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.createFormRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.createFormLabel {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
width: 60px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.createFormInput {
|
||||||
|
flex: 1;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.createFormInput:focus {
|
||||||
|
border-color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.createFormSelect {
|
||||||
|
flex: 1;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.createFormActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.createFormBtn {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.createFormBtnPrimary {
|
||||||
|
composes: createFormBtn;
|
||||||
|
background: var(--amber);
|
||||||
|
border-color: var(--amber);
|
||||||
|
color: #000;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.createFormBtnPrimary:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.createFormError {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--rose);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Detail header with actions ─── */
|
||||||
|
.detailHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailHeaderInfo {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Parent group dropdown ─── */
|
||||||
|
.parentSelect {
|
||||||
|
padding: 3px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|||||||
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