feat: replace recipient dropdown with autocomplete search in compose message
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions

Replace plain <select> dropdown with typeahead autocomplete that filters
users from first character typed. Supports Polish diacritics (NFD
normalization), keyboard navigation, and visual selected-recipient chip.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-03-11 16:49:07 +01:00
parent d6d142feb2
commit 0f8a2d7863

View File

@ -146,6 +146,114 @@
.context-title a:hover {
text-decoration: underline;
}
.recipient-autocomplete {
position: relative;
}
.recipient-autocomplete input[type="text"] {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-base);
transition: var(--transition);
}
.recipient-autocomplete input[type="text"]:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.autocomplete-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--surface);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 var(--radius) var(--radius);
max-height: 240px;
overflow-y: auto;
z-index: 100;
box-shadow: var(--shadow-lg);
display: none;
}
.autocomplete-results.visible {
display: block;
}
.autocomplete-item {
padding: var(--spacing-sm) var(--spacing-md);
cursor: pointer;
display: flex;
align-items: center;
gap: var(--spacing-sm);
transition: background 0.15s;
}
.autocomplete-item:hover,
.autocomplete-item.active {
background: var(--background);
}
.autocomplete-item-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: var(--font-size-sm);
flex-shrink: 0;
}
.autocomplete-item-name {
font-weight: 500;
}
.autocomplete-item-email {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.autocomplete-no-results {
padding: var(--spacing-sm) var(--spacing-md);
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.selected-recipient {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--background);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.selected-recipient-remove {
margin-left: auto;
background: none;
border: none;
cursor: pointer;
color: var(--text-secondary);
padding: 4px;
border-radius: var(--radius-sm);
line-height: 1;
}
.selected-recipient-remove:hover {
color: var(--danger);
background: rgba(239, 68, 68, 0.1);
}
</style>
{% endblock %}
@ -210,13 +318,20 @@
<input type="hidden" name="recipient_id" value="{{ recipient.id }}">
{% else %}
<div class="form-group">
<label for="recipient_id">Do *</label>
<select id="recipient_id" name="recipient_id" required>
<option value="">Wybierz odbiorcę...</option>
{% for user in users %}
<option value="{{ user.id }}">{{ user.name or user.email.split('@')[0] }}{% if user.privacy_show_email != False %} ({{ user.email }}){% endif %}</option>
{% endfor %}
</select>
<label for="recipient_search">Do *</label>
<input type="hidden" id="recipient_id" name="recipient_id" required>
<div id="recipient-selected" class="selected-recipient" style="display: none;">
<div class="autocomplete-item-avatar" id="selected-avatar"></div>
<div>
<div class="autocomplete-item-name" id="selected-name"></div>
<div class="autocomplete-item-email" id="selected-email"></div>
</div>
<button type="button" class="selected-recipient-remove" onclick="clearRecipient()" title="Zmien odbiorcę"></button>
</div>
<div id="recipient-autocomplete" class="recipient-autocomplete">
<input type="text" id="recipient_search" placeholder="Wpisz imie, nazwisko lub email..." autocomplete="off">
<div id="autocomplete-results" class="autocomplete-results"></div>
</div>
</div>
{% endif %}
@ -244,3 +359,134 @@
</div>
</div>
{% endblock %}
{% block extra_js %}
{% if not recipient %}
(function() {
var users = [
{% for user in users %}
{id: {{ user.id }}, name: {{ (user.name or user.email.split('@')[0]) | tojson }}, email: {{ user.email | tojson }}, showEmail: {{ 'true' if user.privacy_show_email != False else 'false' }}}{{ ',' if not loop.last }}
{% endfor %}
];
var searchInput = document.getElementById('recipient_search');
var resultsDiv = document.getElementById('autocomplete-results');
var hiddenInput = document.getElementById('recipient_id');
var selectedDiv = document.getElementById('recipient-selected');
var autocompleteDiv = document.getElementById('recipient-autocomplete');
var activeIndex = -1;
function normalize(str) {
return str.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
function filterUsers(query) {
if (!query) return [];
var q = normalize(query);
return users.filter(function(u) {
return normalize(u.name).indexOf(q) !== -1 || normalize(u.email).indexOf(q) !== -1;
});
}
function renderResults(matches) {
activeIndex = -1;
if (matches.length === 0) {
resultsDiv.innerHTML = '<div class="autocomplete-no-results">Nie znaleziono</div>';
resultsDiv.classList.add('visible');
return;
}
resultsDiv.innerHTML = matches.map(function(u, i) {
var initial = (u.name || u.email)[0].toUpperCase();
var emailPart = u.showEmail ? '<div class="autocomplete-item-email">' + u.email + '</div>' : '';
return '<div class="autocomplete-item" data-index="' + i + '" data-id="' + u.id + '" data-name="' + u.name.replace(/"/g, '&quot;') + '" data-email="' + u.email.replace(/"/g, '&quot;') + '" data-show-email="' + u.showEmail + '">' +
'<div class="autocomplete-item-avatar">' + initial + '</div>' +
'<div><div class="autocomplete-item-name">' + u.name + '</div>' + emailPart + '</div></div>';
}).join('');
resultsDiv.classList.add('visible');
}
function selectRecipient(id, name, email, showEmail) {
hiddenInput.value = id;
document.getElementById('selected-avatar').textContent = (name || email)[0].toUpperCase();
document.getElementById('selected-name').textContent = name;
document.getElementById('selected-email').textContent = showEmail ? email : '';
selectedDiv.style.display = 'flex';
autocompleteDiv.style.display = 'none';
resultsDiv.classList.remove('visible');
searchInput.value = '';
}
window.clearRecipient = function() {
hiddenInput.value = '';
selectedDiv.style.display = 'none';
autocompleteDiv.style.display = 'block';
searchInput.value = '';
searchInput.focus();
};
searchInput.addEventListener('input', function() {
var q = this.value.trim();
if (q.length === 0) {
resultsDiv.classList.remove('visible');
return;
}
renderResults(filterUsers(q));
});
searchInput.addEventListener('focus', function() {
if (this.value.trim().length > 0) {
renderResults(filterUsers(this.value.trim()));
}
});
resultsDiv.addEventListener('click', function(e) {
var item = e.target.closest('.autocomplete-item');
if (item) {
selectRecipient(item.dataset.id, item.dataset.name, item.dataset.email, item.dataset.showEmail === 'true');
}
});
searchInput.addEventListener('keydown', function(e) {
var items = resultsDiv.querySelectorAll('.autocomplete-item');
if (!items.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIndex = Math.min(activeIndex + 1, items.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex = Math.max(activeIndex - 1, 0);
} else if (e.key === 'Enter' && activeIndex >= 0) {
e.preventDefault();
var item = items[activeIndex];
selectRecipient(item.dataset.id, item.dataset.name, item.dataset.email, item.dataset.showEmail === 'true');
return;
} else if (e.key === 'Escape') {
resultsDiv.classList.remove('visible');
return;
} else {
return;
}
items.forEach(function(el) { el.classList.remove('active'); });
items[activeIndex].classList.add('active');
items[activeIndex].scrollIntoView({block: 'nearest'});
});
document.addEventListener('click', function(e) {
if (!autocompleteDiv.contains(e.target)) {
resultsDiv.classList.remove('visible');
}
});
searchInput.closest('form').addEventListener('submit', function(e) {
if (!hiddenInput.value) {
e.preventDefault();
searchInput.focus();
searchInput.style.borderColor = 'var(--danger)';
setTimeout(function() { searchInput.style.borderColor = ''; }, 2000);
}
});
})();
{% endif %}
{% endblock %}