feat: add MultiSelectDropdown component and CRUD styles

This commit is contained in:
hsiegeln
2026-03-17 18:32:16 +01:00
parent 1881aca0e4
commit 65001e0ed0
2 changed files with 394 additions and 0 deletions

View File

@@ -568,3 +568,277 @@
flex-direction: column;
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;
}

View 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>
);
}