Compare commits

...
3 Commits
Author SHA1 Message Date
stefan.tollkuehn a9b7fd11af Announce the suggestion list to screen readers
The invitee input had no combobox semantics: assistive
technology could not tell that the widget owns a listbox with
suggestions. Mark the input as role="combobox" with
aria-autocomplete="list" and an initial aria-expanded="false",
point aria-controls at the list and give the JS-built list a
matching id so the reference resolves.
2026-10-23 14:08:59 +02:00
stefan.tollkuehn 5bba3a93cd Align invitee separators with the server
The server splits the attendee input on whitespace, commas and
semicolons, but the search widget only treated commas and
semicolons as separators. Typing "a@x.com b" therefore sent the
whole string as the search query instead of "b", and picking a
contact dropped the whitespace-separated entry.

Split on /[\s;,]+/ in both fragment() and commit() so the
widget agrees with parseAttendees() on what an entry is.
2026-10-23 14:07:28 +02:00
stefan.tollkuehn e5432a0a38 Fix stale contact suggestions reopening
hide() did not invalidate in-flight search requests. Clearing
the input or picking a contact while a request was pending let
the old response re-render and pop the suggestion list back
open. Bump requestSeq in hide() so every dismissal retires
pending responses.

Add a Vitest + jsdom harness with black-box tests for the
invitee widget, covering the stale-response race both on
field clear and on contact pick.
2026-10-23 14:04:32 +02:00
7 changed files with 2345 additions and 2 deletions

No files matched your search

+1
View File
@@ -1,2 +1,3 @@
/vendor/ /vendor/
node_modules/
.phpunit.result.cache .phpunit.result.cache
+6 -2
View File
@@ -13,7 +13,9 @@
'use strict'; 'use strict';
var DEBOUNCE_MS = 250; var DEBOUNCE_MS = 250;
var SEPARATOR = /[;,]/; /* Must match the server's attendee parsing: whitespace, comma and
* semicolon all separate entries. */
var SEPARATOR = /[\s;,]+/;
var input = document.getElementById('atocal-attendees'); var input = document.getElementById('atocal-attendees');
@@ -28,6 +30,7 @@
} }
var list = document.createElement('ul'); var list = document.createElement('ul');
list.id = 'atocal-suggestions';
list.className = 'atocal-suggestions'; list.className = 'atocal-suggestions';
list.hidden = true; list.hidden = true;
list.setAttribute('role', 'listbox'); list.setAttribute('role', 'listbox');
@@ -49,6 +52,7 @@
} }
function hide() { function hide() {
requestSeq++;
list.hidden = true; list.hidden = true;
list.replaceChildren(); list.replaceChildren();
suggestions = []; suggestions = [];
@@ -58,7 +62,7 @@
} }
function commit(email) { function commit(email) {
var parts = input.value.split(/([;,])/); var parts = input.value.split(/([\s;,]+)/);
parts.pop(); parts.pop();
var prefix = parts.join('').replace(/\s+$/, ''); var prefix = parts.join('').replace(/\s+$/, '');
+2148
View File
File diff suppressed because it is too large. Load diff
+11
View File
@@ -0,0 +1,11 @@
{
"private": true,
"description": "JS test harness for the atocal frontend scripts",
"scripts": {
"test:js": "vitest run"
},
"devDependencies": {
"jsdom": "^26.1.0",
"vitest": "^3.2.4"
}
}
+4
View File
@@ -140,6 +140,10 @@ $l = $_['l10n'];
id="atocal-attendees" id="atocal-attendees"
name="attendees" name="attendees"
type="text" type="text"
role="combobox"
aria-autocomplete="list"
aria-controls="atocal-suggestions"
aria-expanded="false"
list="atocal-contact-emails" list="atocal-contact-emails"
data-search-url="<?= $h($_['contactsSearchUrl']) ?>" data-search-url="<?= $h($_['contactsSearchUrl']) ?>"
value="<?= $h($_['attendees']) ?>" value="<?= $h($_['attendees']) ?>"
+167
View File
@@ -0,0 +1,167 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const DEBOUNCE_MS = 250;
const SEARCH_URL = '/apps/atocal/contact/search';
const BOB = { name: 'Bob', email: 'bob@example.com' };
const ZOE = { name: 'Zoe', email: 'zoe@example.com' };
let input;
let list;
function response(contacts) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(contacts),
});
}
/* Promise we can settle manually, to emulate an in-flight request */
function pendingResponse() {
let resolve;
const promise = new Promise((res) => {
resolve = res;
});
return { promise, resolve };
}
function type(fragment) {
input.value = fragment;
input.dispatchEvent(new Event('input', { bubbles: true }));
}
/* Let the debounce fire and the promise chain settle */
async function settle(ms = DEBOUNCE_MS) {
await vi.advanceTimersByTimeAsync(ms);
}
beforeAll(async () => {
document.body.innerHTML =
'<span><input id="atocal-attendees" data-search-url="' + SEARCH_URL + '"'
+ ' role="combobox" aria-autocomplete="list"'
+ ' aria-controls="atocal-suggestions" aria-expanded="false"></span>';
await import('../../js/invitees.js');
input = document.getElementById('atocal-attendees');
list = document.querySelector('.atocal-suggestions');
});
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal('fetch', vi.fn());
});
afterEach(async () => {
/* Empty fragment hides the list and cancels pending debounces */
type('');
await settle(0);
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe('invitee suggestions', () => {
it('marks the input as a combobox controlling the list', () => {
expect(input.getAttribute('role')).toBe('combobox');
expect(input.getAttribute('aria-autocomplete')).toBe('list');
/* aria-controls must resolve to the list built by the widget */
const controlled = document.getElementById(
input.getAttribute('aria-controls')
);
expect(controlled).toBe(list);
expect(input.getAttribute('aria-expanded')).toBe('false');
});
it('renders matching contacts', async () => {
fetch.mockReturnValue(response([BOB]));
type('bo');
await settle();
expect(fetch).toHaveBeenCalledTimes(1);
expect(list.hidden).toBe(false);
expect(list.querySelectorAll('[role="option"]')).toHaveLength(1);
expect(list.textContent).toContain('bob@example.com');
expect(input.getAttribute('aria-expanded')).toBe('true');
});
it('ignores responses that arrive after the field was cleared', async () => {
const inFlight = pendingResponse();
fetch.mockReturnValue(inFlight.promise);
type('bo');
await settle();
/* User clears the field while the request is still in flight */
type('');
inFlight.resolve({
ok: true,
json: () => Promise.resolve([BOB]),
});
await settle(0);
expect(list.hidden).toBe(true);
expect(list.children).toHaveLength(0);
expect(input.getAttribute('aria-expanded')).toBe('false');
});
it('ignores responses that arrive after a contact was picked', async () => {
const second = pendingResponse();
fetch
.mockReturnValueOnce(response([BOB]))
.mockReturnValueOnce(second.promise);
type('bo');
await settle();
expect(list.hidden).toBe(false);
/* Typing more triggers a second request */
type('bob');
await settle();
/* User picks the visible suggestion while the request is in flight */
list.querySelector('button').dispatchEvent(
new MouseEvent('mousedown', { bubbles: true, cancelable: true })
);
expect(input.value).toBe('bob@example.com, ');
expect(list.hidden).toBe(true);
/* The stale response must not reopen the list */
second.resolve({
ok: true,
json: () => Promise.resolve([ZOE]),
});
await settle(0);
expect(list.hidden).toBe(true);
expect(list.children).toHaveLength(0);
});
it('searches only the last whitespace-separated fragment', async () => {
fetch.mockReturnValue(response([BOB]));
type('alice@example.com bo');
await settle();
expect(fetch).toHaveBeenCalledWith(
SEARCH_URL + '?query=bo',
expect.objectContaining({ headers: { Accept: 'application/json' } })
);
});
it('keeps whitespace-separated entries when picking a contact', async () => {
fetch.mockReturnValue(response([BOB]));
type('alice@example.com bo');
await settle();
list.querySelector('button').dispatchEvent(
new MouseEvent('mousedown', { bubbles: true, cancelable: true })
);
expect(input.value).toBe('alice@example.com bob@example.com, ');
});
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
include: ['tests/js/**/*.test.mjs'],
},
});