/**
* User typeahead — search-as-you-type picker that resolves any
* identifier (username, identity hex, userId, display name) to a
* canonical userId before submission.
*
* Markup contract:
*
* <div class="pgv3-user-picker">
* <input type="text" data-pgv3-user-pick="member-userid-hidden"
* placeholder="Search by name, username, or identity…">
* <input type="hidden" id="member-userid-hidden" name="userId">
* <div class="pgv3-user-picker-dropdown" hidden></div>
* <div class="pgv3-user-picker-status" aria-live="polite"></div>
* </div>
*
* On selection, the visible input shows "Alice (d07ad368…)" and the
* hidden field is set to the canonical userId. Submitting the form
* with an empty hidden field is blocked client-side. The backend
* `add-org-member` handler is the authoritative gate — this JS is
* UX only.
*/
(function () {
function esc (s) {
if (s == null) return ''
return String(s)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
}
function fmtUser (u) {
// Display priority: name → username → primaryIdentity → userId
const label = u.name || u.username || (u.primaryIdentity ? u.primaryIdentity.slice(0, 12) + '…' : u.userId.slice(0, 12) + '…')
const sub = u.username && u.username !== label
? '@' + u.username
: (u.primaryIdentity ? u.primaryIdentity.slice(0, 16) + '…' : u.userId.slice(0, 16) + '…')
return { label, sub }
}
function attach (visibleInput) {
const hiddenId = visibleInput.dataset.pgv3UserPick
if (!hiddenId) return
const hidden = document.getElementById(hiddenId)
if (!hidden) return
const wrap = visibleInput.closest('.pgv3-user-picker')
if (!wrap) return
const dropdown = wrap.querySelector('.pgv3-user-picker-dropdown')
const status = wrap.querySelector('.pgv3-user-picker-status')
if (!dropdown) return
const pfx = (document.body && document.body.dataset.prefix) || ''
let debounce = null
let lastResults = []
function hide () { dropdown.hidden = true }
function show () { dropdown.hidden = false }
function clearSelection () {
hidden.value = ''
if (status) status.textContent = ''
visibleInput.classList.remove('resolved')
}
function select (u) {
hidden.value = u.userId
const { label, sub } = fmtUser(u)
visibleInput.value = label + ' (' + sub + ')'
visibleInput.classList.add('resolved')
if (status) status.textContent = '✓ ' + label
hide()
}
function render (q, rows) {
lastResults = rows
if (!rows.length) {
dropdown.innerHTML = '<div class="pgv3-user-picker-empty">No users match</div>'
show()
return
}
dropdown.innerHTML = rows.map((u, i) => {
const { label, sub } = fmtUser(u)
const perms = (u.globalPermissions || []).join(',') || 'read'
return '<div class="pgv3-user-picker-row" data-idx="' + i + '" tabindex="0">' +
'<div class="pgv3-user-picker-row-label">' + esc(label) + '</div>' +
'<div class="pgv3-user-picker-row-sub">' + esc(sub) + ' · ' + esc(perms) + '</div>' +
'</div>'
}).join('')
show()
}
async function run (q) {
if (status) status.textContent = 'searching…'
try {
const r = await fetch(pfx + '/api/search/users?q=' + encodeURIComponent(q) + '&limit=10')
if (!r.ok) throw new Error('search failed: ' + r.status)
const rows = await r.json()
render(q, rows)
if (status) status.textContent = rows.length + ' match' + (rows.length === 1 ? '' : 'es')
} catch (err) {
if (status) status.textContent = 'error: ' + err.message
dropdown.innerHTML = '<div class="pgv3-user-picker-empty">search failed</div>'
show()
}
}
visibleInput.addEventListener('input', () => {
clearSelection()
const q = visibleInput.value.trim()
clearTimeout(debounce)
if (q.length < 2) { hide(); if (status) status.textContent = ''; return }
debounce = setTimeout(() => run(q), 150)
})
visibleInput.addEventListener('focus', () => {
if (lastResults.length && visibleInput.value.trim().length >= 2 && !hidden.value) show()
})
visibleInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { hide() }
else if (e.key === 'ArrowDown') {
const first = dropdown.querySelector('.pgv3-user-picker-row')
if (first) first.focus()
e.preventDefault()
}
})
dropdown.addEventListener('click', (e) => {
const row = e.target.closest('.pgv3-user-picker-row')
if (!row) return
const idx = parseInt(row.dataset.idx, 10)
const u = lastResults[idx]
if (u) select(u)
})
dropdown.addEventListener('keydown', (e) => {
const row = e.target.closest('.pgv3-user-picker-row')
if (!row) return
if (e.key === 'Enter' || e.key === ' ') {
const idx = parseInt(row.dataset.idx, 10)
const u = lastResults[idx]
if (u) select(u)
e.preventDefault()
} else if (e.key === 'ArrowDown' && row.nextElementSibling) {
row.nextElementSibling.focus()
e.preventDefault()
} else if (e.key === 'ArrowUp') {
if (row.previousElementSibling) row.previousElementSibling.focus()
else visibleInput.focus()
e.preventDefault()
} else if (e.key === 'Escape') {
hide()
visibleInput.focus()
}
})
document.addEventListener('click', (e) => {
if (!e.target.closest('.pgv3-user-picker')) hide()
})
// Submit guard — block the form if no user is resolved.
const form = visibleInput.closest('form')
if (form) {
form.addEventListener('submit', (e) => {
if (!hidden.value) {
e.preventDefault()
if (status) status.textContent = 'pick a user from the dropdown first'
visibleInput.focus()
}
})
}
}
function init () {
document.querySelectorAll('input[data-pgv3-user-pick]').forEach(attach)
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init)
} else {
init()
}
})()