Add live contact search to invitee input

The static datalist only carried the first 50 address book
contacts, so typed fragments could not match anything beyond
that. A new read-only GET /contacts/search endpoint queries
the full address book per keystroke, and a small vanilla JS
combobox shows the results and replaces only the fragment
after the last separator, so comma-separated lists keep
working. The server-rendered datalist remains as the no-JS
fallback; the JS removes the list attribute at runtime so
the native popup does not double the custom one.
This commit is contained in:
stefan.tollkuehn committed 2026-10-22 18:50:46 +02:00
1 parent 0e5b190971
commit cc1fd4e508
7 files changed
+342 -9

No files matched your search

+5
View File
@@ -14,5 +14,10 @@ return [
'url' => '/create', 'url' => '/create',
'verb' => 'POST', 'verb' => 'POST',
], ],
[
'name' => 'calendar#searchContacts',
'url' => '/contacts/search',
'verb' => 'GET',
],
], ],
]; ];
+43
View File
@@ -112,6 +112,49 @@
appearance: none; appearance: none;
} }
/*
* Live contact search results, anchored to the invitee wrapper.
*/
.atocal-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin: 2px 0 0;
padding: 4px;
list-style: none;
background: var(--color-main-background);
border: 1px solid var(--color-border);
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
z-index: 10;
max-height: 220px;
overflow-y: auto;
}
.atocal-suggestions button {
display: flex;
justify-content: space-between;
gap: 12px;
width: 100%;
padding: 6px 8px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--color-main-text);
text-align: left;
cursor: pointer;
}
.atocal-suggestions li.atocal-active > button,
.atocal-suggestions button:hover {
background: var(--color-background-hover);
}
.atocal-suggestions .atocal-suggestion-email {
color: var(--color-text-maxcontrast);
}
.atocal-row select, .atocal-row select,
.atocal-row input[type="text"] { .atocal-row input[type="text"] {
align-self: center; align-self: center;
+187
View File
@@ -0,0 +1,187 @@
/**
* Live contact search for the invitee input.
*
* A picked suggestion replaces the fragment after the last
* separator, so a comma-separated list keeps working:
*
* "alice@x.com, bo" -> pick "Bob" -> "alice@x.com, bob@x.com, "
*
* Without JavaScript the input falls back to the static
* datalist rendered by the server.
*/
(function () {
'use strict';
var DEBOUNCE_MS = 250;
var SEPARATOR = /[;,]/;
var input = document.getElementById('atocal-attendees');
if (!input) {
return;
}
var searchUrl = input.dataset.searchUrl;
if (!searchUrl) {
return;
}
var list = document.createElement('ul');
list.className = 'atocal-suggestions';
list.hidden = true;
list.setAttribute('role', 'listbox');
/* The wrapper span provides position: relative */
input.parentNode.appendChild(list);
/* The native datalist popup would double the custom one */
input.removeAttribute('list');
var debounceTimer = null;
var requestSeq = 0;
var suggestions = [];
var activeIndex = -1;
function fragment() {
var parts = input.value.split(SEPARATOR);
return parts[parts.length - 1].trim();
}
function hide() {
list.hidden = true;
list.replaceChildren();
suggestions = [];
activeIndex = -1;
input.setAttribute('aria-expanded', 'false');
input.removeAttribute('aria-activedescendant');
}
function commit(email) {
var parts = input.value.split(/([;,])/);
parts.pop();
var prefix = parts.join('').replace(/\s+$/, '');
input.value = prefix === '' ? email + ', ' : prefix + ' ' + email + ', ';
hide();
input.focus();
}
function moveSelection(offset) {
activeIndex = (activeIndex + offset + suggestions.length) % suggestions.length;
list.childNodes.forEach(function (option, i) {
option.classList.toggle('atocal-active', i === activeIndex);
});
input.setAttribute('aria-activedescendant', 'atocal-suggestion-' + activeIndex);
}
function render(contacts) {
hide();
if (contacts.length === 0) {
return;
}
suggestions = contacts;
contacts.forEach(function (contact, i) {
var option = document.createElement('li');
option.id = 'atocal-suggestion-' + i;
option.setAttribute('role', 'option');
var button = document.createElement('button');
button.type = 'button';
button.tabIndex = -1;
button.addEventListener('mousedown', function (event) {
/* keep focus on the input */
event.preventDefault();
commit(contact.email);
});
var name = document.createElement('span');
name.className = 'atocal-suggestion-name';
name.textContent = contact.name === '' ? contact.email : contact.name;
var email = document.createElement('span');
email.className = 'atocal-suggestion-email';
email.textContent = contact.email;
button.appendChild(name);
button.appendChild(email);
option.appendChild(button);
list.appendChild(option);
});
list.hidden = false;
input.setAttribute('aria-expanded', 'true');
}
function search(query) {
var seq = ++requestSeq;
fetch(searchUrl + '?query=' + encodeURIComponent(query), {
headers: { Accept: 'application/json' }
})
.then(function (response) {
if (!response.ok) {
throw new Error('search failed');
}
return response.json();
})
.then(function (contacts) {
if (seq === requestSeq) {
render(Array.isArray(contacts) ? contacts : []);
}
})
.catch(function () {
if (seq === requestSeq) {
hide();
}
});
}
input.addEventListener('input', function () {
clearTimeout(debounceTimer);
var query = fragment();
if (query === '') {
hide();
return;
}
debounceTimer = setTimeout(function () {
search(query);
}, DEBOUNCE_MS);
});
input.addEventListener('keydown', function (event) {
if (list.hidden) {
return;
}
switch (event.key) {
case 'ArrowDown':
moveSelection(1);
event.preventDefault();
break;
case 'ArrowUp':
moveSelection(-1);
event.preventDefault();
break;
case 'Enter':
if (activeIndex >= 0) {
commit(suggestions[activeIndex].email);
event.preventDefault();
}
break;
case 'Escape':
hide();
break;
}
});
document.addEventListener('click', function (event) {
if (!list.hidden && event.target !== input && !list.contains(event.target)) {
hide();
}
});
})();
+25 -6
View File
@@ -11,6 +11,7 @@ use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\RedirectResponse; use OCP\AppFramework\Http\RedirectResponse;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Http\TemplateResponse;
use OCP\Calendar\ICreateFromString; use OCP\Calendar\ICreateFromString;
use OCP\Calendar\IManager; use OCP\Calendar\IManager;
@@ -292,6 +293,19 @@ class CalendarController extends Controller
return new RedirectResponse($calendarUrl); return new RedirectResponse($calendarUrl);
} }
/**
* Live contact search for the invitee input.
*
* Read-only GET endpoint, needs no CSRF protection.
*
* @return DataResponse<Http::STATUS_OK, list<array{email: string, name: string}>>
*/
#[NoAdminRequired]
public function searchContacts(string $query = ''): DataResponse
{
return new DataResponse($this->contactSuggestions($query));
}
/** /**
* All writable calendars of the given principal. * All writable calendars of the given principal.
* *
@@ -402,10 +416,11 @@ class CalendarController extends Controller
array $contacts = [] array $contacts = []
): TemplateResponse { ): TemplateResponse {
/* /*
* Stylesheets are not auto-loaded, css/style.css * Stylesheets and scripts are not auto-loaded, they must
* must be registered for the page layout. * be registered for the page layout.
*/ */
Util::addStyle(Application::APP_ID, 'style'); Util::addStyle(Application::APP_ID, 'style');
Util::addScript(Application::APP_ID, 'invitees');
return new TemplateResponse( return new TemplateResponse(
Application::APP_ID, Application::APP_ID,
@@ -432,6 +447,9 @@ class CalendarController extends Controller
'calendarId' => $calendarId, 'calendarId' => $calendarId,
'attendees' => $attendees, 'attendees' => $attendees,
'contacts' => $contacts, 'contacts' => $contacts,
'contactsSearchUrl' => $this->urlGenerator->linkToRoute(
Application::APP_ID . '.calendar.searchContacts'
),
'cancelUrl' => $this->urlGenerator->getAbsoluteURL('/'), 'cancelUrl' => $this->urlGenerator->getAbsoluteURL('/'),
'createUrl' => $this->urlGenerator->linkToRoute( 'createUrl' => $this->urlGenerator->linkToRoute(
Application::APP_ID . '.calendar.create' Application::APP_ID . '.calendar.create'
@@ -485,16 +503,17 @@ class CalendarController extends Controller
} }
/** /**
* Address book entries with an email address, offered as * Address book entries with an email address, offered by the
* autocomplete options by the invite input. * invitee input: as autocomplete options on the initial page
* and as live search results while typing.
* *
* @return list<array{email: string, name: string}> * @return list<array{email: string, name: string}>
*/ */
private function contactSuggestions(): array private function contactSuggestions(string $query = ''): array
{ {
$contacts = []; $contacts = [];
$results = $this->contactsManager->search('', ['FN', 'EMAIL']); $results = $this->contactsManager->search(trim($query), ['FN', 'EMAIL']);
foreach ($results ?? [] as $result) { foreach ($results ?? [] as $result) {
$name = (string)($result['FN'] ?? ''); $name = (string)($result['FN'] ?? '');
+1
View File
@@ -141,6 +141,7 @@ $l = $_['l10n'];
name="attendees" name="attendees"
type="text" type="text"
list="atocal-contact-emails" list="atocal-contact-emails"
data-search-url="<?= $h($_['contactsSearchUrl']) ?>"
value="<?= $h($_['attendees']) ?>" value="<?= $h($_['attendees']) ?>"
placeholder="<?= $h( placeholder="<?= $h(
$l->t('Email addresses, comma-separated') $l->t('Email addresses, comma-separated')
+42
View File
@@ -6,6 +6,7 @@ namespace OCA\AToCal\Tests\Unit\Controller;
use OCA\AToCal\Controller\CalendarController; use OCA\AToCal\Controller\CalendarController;
use OCP\App\IAppManager; use OCP\App\IAppManager;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\RedirectResponse; use OCP\AppFramework\Http\RedirectResponse;
use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Http\TemplateResponse;
use OCP\Calendar\ICalendar; use OCP\Calendar\ICalendar;
@@ -898,4 +899,45 @@ class CalendarControllerTest extends TestCase
$this->assertSame('alice@example.com, not-an-email', $params['attendees']); $this->assertSame('alice@example.com, not-an-email', $params['attendees']);
$this->assertSame('5::personal', $params['calendarId']); $this->assertSame('5::personal', $params['calendarId']);
} }
/*
* searchContacts()
*/
public function testSearchContactsReturnsMatches(): void
{
$this->contactsManager->expects($this->once())->method('search')
->with('ali', ['FN', 'EMAIL'])
->willReturn([
['FN' => 'Alice Example', 'EMAIL' => 'alice@example.com'],
]);
$c = $this->newController();
$response = $c->searchContacts('ali');
$this->assertInstanceOf(DataResponse::class, $response);
$this->assertSame(
[['email' => 'alice@example.com', 'name' => 'Alice Example']],
$response->getData()
);
}
public function testSearchContactsCapsResults(): void
{
$contacts = array_map(
static fn (int $i): array => [
'FN' => "Person $i",
'EMAIL' => "person$i@example.com",
],
range(1, 60)
);
$this->contactsManager->method('search')->willReturn($contacts);
$c = $this->newController();
$response = $c->searchContacts('person');
$this->assertCount(50, $response->getData());
}
} }
+39 -3
View File
@@ -3,10 +3,43 @@
declare(strict_types=1); declare(strict_types=1);
/* /*
* Minimal stand-in for the server class OC_Util, which is not part * Minimal stand-ins for server classes that are not part of
* of the OCP vendor package. Records addStyle() calls for assertions. * the OCP vendor package. OC_Util records addStyle() calls,
* the OC classes satisfy the lookups that OCP\Util::addScript()
* triggers for app scripts.
*/ */
if (!class_exists(\OC_Util::class, false)) { namespace OC {
class AppScriptDependency
{
public function __construct(string $app, array $deps = []) {}
public function addDep(string $dep): void {}
}
}
namespace {
class OC
{
public static ?object $server = null;
}
class AToCalFakeL10NFactory
{
public function findLanguage(string $app): string
{
return 'en';
}
}
class AToCalFakeServer
{
public function get(string $serviceName): AToCalFakeL10NFactory
{
return new AToCalFakeL10NFactory();
}
}
if (!class_exists('OC_Util', false)) {
class OC_Util class OC_Util
{ {
/** @var list<array{string, ?string, bool}> */ /** @var list<array{string, ?string, bool}> */
@@ -20,4 +53,7 @@ if (!class_exists(\OC_Util::class, false)) {
self::$styles[] = [$application, $file, $prepend]; self::$styles[] = [$application, $file, $prepend];
} }
} }
}
OC::$server ??= new AToCalFakeServer();
} }