// Shared UI primitives: Field, Select, MultiSelect (chip input), Modal, Toast.
const Field = ({ label, required, optional, hint, error, children }) => (
{label}
{required && *}
{optional && (optional)}
{children}
{error ?
{error}
: hint ?
{hint}
: null}
);
const TextInput = React.forwardRef(({ error, ...props }, ref) => (
));
const Select = ({ value, onChange, options, placeholder = "Select…", error, ...rest }) => (
);
// Multi-select chip input with type-ahead suggestions
const MultiSelect = ({ values = [], onChange, options = [], placeholder = "Type to add…", allowCustom = false }) => {
const [text, setText] = React.useState("");
const [open, setOpen] = React.useState(false);
const [activeIdx, setActiveIdx] = React.useState(0);
const inputRef = React.useRef(null);
const remaining = options.filter(o => !values.includes(o));
const filtered = text.trim()
? remaining.filter(o => o.toLowerCase().includes(text.toLowerCase()))
: remaining;
const add = (v) => {
if (!v) return;
if (!values.includes(v)) onChange([...values, v]);
setText("");
setActiveIdx(0);
inputRef.current?.focus();
};
const remove = (v) => onChange(values.filter(x => x !== v));
const onKey = (e) => {
if (e.key === "Backspace" && !text && values.length) {
remove(values[values.length - 1]);
} else if (e.key === "Enter") {
e.preventDefault();
if (filtered[activeIdx]) add(filtered[activeIdx]);
else if (allowCustom && text.trim()) add(text.trim());
} else if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIdx(i => Math.min(i + 1, filtered.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIdx(i => Math.max(i - 1, 0));
} else if (e.key === "Escape") {
setOpen(false);
}
};
return (
inputRef.current?.focus()}>
{values.map(v => (
{v}
))}
{ setText(e.target.value); setOpen(true); setActiveIdx(0); }}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 120)}
onKeyDown={onKey}
/>
{open && filtered.length > 0 && (
{filtered.slice(0, 8).map((o, i) => (
))}
)}
);
};
const Modal = ({ open, onClose, title, sub, children, footer, size = "md" }) => {
React.useEffect(() => {
if (!open) return;
const onKey = (e) => { if (e.key === "Escape") onClose?.(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
{ if (e.target.classList.contains("modal-backdrop")) onClose?.(); }}>
{children}
{footer &&
{footer}
}
);
};
const Toast = ({ msg, onDone }) => {
React.useEffect(() => {
if (!msg) return;
const t = setTimeout(onDone, 2400);
return () => clearTimeout(t);
}, [msg, onDone]);
if (!msg) return null;
return (
{msg}
);
};
const Avatar = ({ name, size = 30 }) => {
const initials = name.split(" ").map(s => s[0]).join("").slice(0, 2).toUpperCase();
return {initials}
;
};
const ConfDot = ({ level }) => {
const map = { public: "Public", internal: "Internal", restricted: "Restricted", confidential: "Confidential", "client confidential": "Confidential" };
const dataLvl = level === "client confidential" ? "confidential" : level;
return {map[level] || level};
};
Object.assign(window, { Field, TextInput, Select, MultiSelect, Modal, Toast, Avatar, ConfDot });