From 65001e0ed0a77f476c38fdc095dcd0ca4897b952 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:32:16 +0100 Subject: [PATCH] feat: add MultiSelectDropdown component and CRUD styles --- ui/src/pages/admin/rbac/RbacPage.module.css | 274 ++++++++++++++++++ .../rbac/components/MultiSelectDropdown.tsx | 120 ++++++++ 2 files changed, 394 insertions(+) create mode 100644 ui/src/pages/admin/rbac/components/MultiSelectDropdown.tsx diff --git a/ui/src/pages/admin/rbac/RbacPage.module.css b/ui/src/pages/admin/rbac/RbacPage.module.css index ea4519d1..c6aa7d53 100644 --- a/ui/src/pages/admin/rbac/RbacPage.module.css +++ b/ui/src/pages/admin/rbac/RbacPage.module.css @@ -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; +} diff --git a/ui/src/pages/admin/rbac/components/MultiSelectDropdown.tsx b/ui/src/pages/admin/rbac/components/MultiSelectDropdown.tsx new file mode 100644 index 00000000..6bff0523 --- /dev/null +++ b/ui/src/pages/admin/rbac/components/MultiSelectDropdown.tsx @@ -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>(new Set()); + const ref = useRef(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 ( +
+ + {open && ( +
+
+ setSearch(e.target.value)} + autoFocus + /> +
+
+ {filtered.length === 0 ? ( +
No items found
+ ) : ( + filtered.map(item => ( + + )) + )} +
+
+ +
+
+ )} +
+ ); +}