Add dropdown with toggle functionality to OwnerPicker
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 6s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s

Introduced a dropdown feature to the OwnerPicker component, allowing users to search or browse owners more effectively. The dropdown can be toggled open/closed and includes improved styling for better user experience. Added click-outside functionality to automatically close the dropdown when users interact elsewhere.
This commit is contained in:
2026-02-12 15:36:09 +01:00
parent 648ff07651
commit 505b93be4f
2 changed files with 84 additions and 26 deletions

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { apiFetch } from "../api"; import { apiFetch } from "../api";
import { useAuth } from "../state"; import { useAuth } from "../state";
@@ -34,13 +34,25 @@ function toggleOwner(ids, userId) {
} }
function OwnerPicker({ candidates, selectedIds, onToggle, query, onQueryChange }) { function OwnerPicker({ candidates, selectedIds, onToggle, query, onQueryChange }) {
const pickerRef = useRef(null);
const [open, setOpen] = useState(false);
const filtered = candidates.filter((item) => const filtered = candidates.filter((item) =>
item.email.toLowerCase().includes(query.trim().toLowerCase()) item.email.toLowerCase().includes(query.trim().toLowerCase())
); );
const selected = candidates.filter((item) => selectedIds.includes(item.user_id)); const selected = candidates.filter((item) => selectedIds.includes(item.user_id));
useEffect(() => {
const onPointerDown = (event) => {
if (!pickerRef.current?.contains(event.target)) {
setOpen(false);
}
};
document.addEventListener("mousedown", onPointerDown);
return () => document.removeEventListener("mousedown", onPointerDown);
}, []);
return ( return (
<div className="owner-picker"> <div className="owner-picker" ref={pickerRef}>
<div className="owner-selected"> <div className="owner-selected">
{selected.length > 0 ? ( {selected.length > 0 ? (
selected.map((item) => ( selected.map((item) => (
@@ -59,30 +71,41 @@ function OwnerPicker({ candidates, selectedIds, onToggle, query, onQueryChange }
<span className="muted">No owners selected yet.</span> <span className="muted">No owners selected yet.</span>
)} )}
</div> </div>
<input <div className={`owner-search-shell ${open ? "open" : ""}`}>
type="text" <input
className="owner-search-input" type="text"
value={query} className="owner-search-input"
onChange={(e) => onQueryChange(e.target.value)} value={query}
placeholder="Search users by email..." onChange={(e) => onQueryChange(e.target.value)}
/> onFocus={() => setOpen(true)}
<div className="owner-search-results"> onClick={() => setOpen(true)}
{filtered.map((item) => { placeholder="Search users by email..."
const active = selectedIds.includes(item.user_id); />
return ( <button type="button" className="owner-search-toggle" onClick={() => setOpen((prev) => !prev)}>
<button v
key={item.user_id} </button>
type="button"
className={`owner-result ${active ? "active" : ""}`}
onClick={() => onToggle(item.user_id)}
>
<span>{item.email}</span>
<small>{item.role}</small>
</button>
);
})}
{filtered.length === 0 && <div className="owner-result-empty">No matching users.</div>}
</div> </div>
{open && (
<div className="owner-dropdown">
<div className="owner-search-results">
{filtered.map((item) => {
const active = selectedIds.includes(item.user_id);
return (
<button
key={item.user_id}
type="button"
className={`owner-result ${active ? "active" : ""}`}
onClick={() => onToggle(item.user_id)}
>
<span>{item.email}</span>
<small>{item.role}</small>
</button>
);
})}
{filtered.length === 0 && <div className="owner-result-empty">No matching users.</div>}
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -752,6 +752,7 @@ button {
.owner-picker { .owner-picker {
display: grid; display: grid;
gap: 8px; gap: 8px;
position: relative;
} }
.owner-selected { .owner-selected {
@@ -774,14 +775,48 @@ button {
font-weight: 600; font-weight: 600;
} }
.owner-search-shell {
display: grid;
grid-template-columns: 1fr auto;
gap: 0;
}
.owner-search-shell.open .owner-search-input {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.owner-search-input { .owner-search-input {
width: 100%; width: 100%;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.owner-search-toggle {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
min-width: 38px;
font-weight: 700;
padding: 0 10px;
}
.owner-dropdown {
position: absolute;
left: 0;
right: 0;
top: calc(100% - 2px);
z-index: 10;
border: 1px solid #3c73ac;
border-radius: 0 0 10px 10px;
background: #0f2549;
box-shadow: 0 12px 26px #03112777;
padding: 8px;
} }
.owner-search-results { .owner-search-results {
display: grid; display: grid;
gap: 6px; gap: 6px;
max-height: 150px; max-height: 180px;
overflow: auto; overflow: auto;
padding-right: 2px; padding-right: 2px;
} }