It's not strictly HTML, so that's just correct. This is just worth doing while building the new TypeScript bundle
243 lines
5.5 KiB
JavaScript
243 lines
5.5 KiB
JavaScript
class UserSelector extends HTMLElement {
|
|
constructor() {
|
|
super();
|
|
this.attachShadow({ mode: "open" });
|
|
this.selectedUser = null;
|
|
this.debounceTimer = null;
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.render();
|
|
this.setupEventListeners();
|
|
}
|
|
|
|
render() {
|
|
this.shadowRoot.innerHTML = `
|
|
<link href="/static/css/bootstrap.css" rel="stylesheet">
|
|
<style>
|
|
:host {
|
|
display: block;
|
|
position: relative;
|
|
}
|
|
|
|
.suggestions-dropdown {
|
|
position: absolute;
|
|
top: 100%;
|
|
left: 0;
|
|
right: 0;
|
|
z-index: 1000;
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
display: none;
|
|
}
|
|
|
|
.suggestions-dropdown.show {
|
|
display: block;
|
|
}
|
|
|
|
.suggestion-item {
|
|
cursor: pointer;
|
|
border-bottom: 1px solid #dee2e6;
|
|
}
|
|
|
|
.suggestion-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.suggestion-item:hover {
|
|
background-color: #f8f9fa;
|
|
}
|
|
|
|
.user-display-name {
|
|
font-weight: 500;
|
|
color: #212529;
|
|
}
|
|
|
|
.user-username {
|
|
font-size: 0.875rem;
|
|
color: #6c757d;
|
|
}
|
|
|
|
.user-org {
|
|
font-size: 0.875rem;
|
|
color: #6c757d;
|
|
}
|
|
|
|
.loading {
|
|
text-align: center;
|
|
padding: 0.75rem;
|
|
color: #6c757d;
|
|
}
|
|
</style>
|
|
|
|
<div class="user-selector-container">
|
|
<input
|
|
type="text"
|
|
class="form-control"
|
|
placeholder="Type to search users (min. 4 characters)..."
|
|
id="userInput"
|
|
autocomplete="off"
|
|
/>
|
|
|
|
<div class="suggestions-dropdown card shadow-sm" id="suggestionsDropdown">
|
|
<div class="list-group list-group-flush" id="suggestionsList">
|
|
<!-- Suggestions will be inserted here -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
setupEventListeners() {
|
|
const input = this.shadowRoot.getElementById("userInput");
|
|
const dropdown = this.shadowRoot.getElementById("suggestionsDropdown");
|
|
|
|
input.addEventListener("input", (e) => this.handleInput(e));
|
|
input.addEventListener("focus", (e) => {
|
|
if (e.target.value.length >= 4) {
|
|
this.handleInput(e);
|
|
}
|
|
});
|
|
|
|
// Close dropdown when clicking outside
|
|
document.addEventListener("click", (e) => {
|
|
if (!this.contains(e.target)) {
|
|
this.hideSuggestions();
|
|
}
|
|
});
|
|
}
|
|
|
|
handleInput(e) {
|
|
const query = e.target.value;
|
|
|
|
// Clear previous timer
|
|
clearTimeout(this.debounceTimer);
|
|
|
|
if (query.length < 4) {
|
|
this.hideSuggestions();
|
|
return;
|
|
}
|
|
|
|
// Debounce API calls
|
|
this.debounceTimer = setTimeout(() => {
|
|
this.fetchSuggestions(query);
|
|
}, 300);
|
|
}
|
|
|
|
async fetchSuggestions(query) {
|
|
const suggestionsList = this.shadowRoot.getElementById("suggestionsList");
|
|
const dropdown = this.shadowRoot.getElementById("suggestionsDropdown");
|
|
|
|
// Show loading state
|
|
suggestionsList.innerHTML = '<div class="loading">Loading...</div>';
|
|
dropdown.classList.add("show");
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`/api/user/suggestion?query=${encodeURIComponent(query)}`,
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Network response was not ok");
|
|
}
|
|
|
|
const data = await response.json();
|
|
this.displaySuggestions(data.users);
|
|
} catch (error) {
|
|
console.error("Error fetching suggestions:", error);
|
|
suggestionsList.innerHTML = `
|
|
<div class="alert alert-danger m-2" role="alert">
|
|
Error loading suggestions. Please try again.
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
displaySuggestions(users) {
|
|
const suggestionsList = this.shadowRoot.getElementById("suggestionsList");
|
|
const dropdown = this.shadowRoot.getElementById("suggestionsDropdown");
|
|
|
|
if (!users || users.length === 0) {
|
|
suggestionsList.innerHTML = `
|
|
<div class="loading">No users found</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
suggestionsList.innerHTML = users
|
|
.map(
|
|
(user) => `
|
|
<div class="list-group-item list-group-item-action suggestion-item" data-user='${JSON.stringify(user)}'>
|
|
<div class="d-flex w-100 justify-content-between align-items-start">
|
|
<div class="flex-grow-1">
|
|
<div class="user-display-name">${this.escapeHtml(user.display_name)}</div>
|
|
<div class="user-username">@${this.escapeHtml(user.username)}</div>
|
|
</div>
|
|
<div class="text-end">
|
|
<span class="badge bg-secondary user-org">${this.escapeHtml(user.organization.name)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`,
|
|
)
|
|
.join("");
|
|
|
|
// Add click handlers to suggestion items
|
|
suggestionsList.querySelectorAll(".suggestion-item").forEach((item) => {
|
|
item.addEventListener("click", (e) => {
|
|
const userData = JSON.parse(e.currentTarget.getAttribute("data-user"));
|
|
this.selectUser(userData);
|
|
});
|
|
});
|
|
|
|
dropdown.classList.add("show");
|
|
}
|
|
|
|
selectUser(user) {
|
|
this.selectedUser = user;
|
|
const input = this.shadowRoot.getElementById("userInput");
|
|
input.value = user.displayName || user.display_name;
|
|
this.hideSuggestions();
|
|
|
|
// Dispatch custom event
|
|
this.dispatchEvent(
|
|
new CustomEvent("user-selected", {
|
|
detail: { user },
|
|
bubbles: true,
|
|
composed: true,
|
|
}),
|
|
);
|
|
}
|
|
|
|
hideSuggestions() {
|
|
const dropdown = this.shadowRoot.getElementById("suggestionsDropdown");
|
|
dropdown.classList.remove("show");
|
|
}
|
|
|
|
escapeHtml(text) {
|
|
const map = {
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
'"': """,
|
|
"'": "'",
|
|
};
|
|
return text.replace(/[&<>"']/g, (m) => map[m]);
|
|
}
|
|
|
|
// Public method to get selected user
|
|
getSelectedUser() {
|
|
return this.selectedUser;
|
|
}
|
|
|
|
// Public method to clear selection
|
|
clear() {
|
|
this.selectedUser = null;
|
|
const input = this.shadowRoot.getElementById("userInput");
|
|
input.value = "";
|
|
this.hideSuggestions();
|
|
}
|
|
}
|
|
|
|
// Register the custom element
|
|
customElements.define("user-selector", UserSelector);
|