fix(logos): Picker hält den Fokus bei „Mehr laden“/„Nochmal versuchen“, Raster als Liste, Cursor ohne Vorgänger

- logo-picker.ts: Verschwindet „Mehr laden“ oder „Nochmal versuchen“, während es den Fokus hat, geht der Fokus weiter statt auf der Seite verloren zu
  gehen: an den Knopf, der seinen Platz einnimmt („Nochmal versuchen“ nach einem Fehler beim Nachladen), sonst an die erste Kachel, die der Ladevorgang
  angehängt hat, sonst an die Überschrift „Logo unten rechts“ (jetzt tabindex −1). Ein gesperrter Knopf verliert den Fokus, darum bleibt „Mehr laden“
  während des Ladens bedienbar, solange es ihn hat (ein zweiter Druck ist wirkungslos, load prüft es); ohne Fokus bleibt es gesperrt wie bisher.
  Wer den Fokus woanders hat, bekommt ihn nicht weggenommen.
- logo-picker.ts: das Raster hat role="list" (list-style: none nimmt dem <ul> sonst die Listensemantik) und kein eigenes aria-labelledby mehr; der
  Name steht an der Gruppe. Das lokale `const select` in createTile verdeckte die Funktion select() und heißt jetzt selectButton.
- logo-picker.ts, removeLogo: War das gelöschte Logo der Cursor und steht kein früheres mehr in der Liste, wurde der Cursor verworfen und „Mehr laden“
  verschwand samt aller weiteren Seiten. Jetzt beginnt die Ansicht wieder bei Seite 1 (load('reset')).
- Tests: Fokus in allen Fällen (letzte Seite, weitere Seite, Fehler, Retry bei leerer und gefüllter Liste, Fokus woanders, nur schon bekannte Logos,
  nach dem Aufräumen), role/aria-labelledby/tabindex, Cursor ohne Vorgänger (auch in der Admin-Ansicht „Gemeldet“) und die Fälle ohne Neuladen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 05:49:04 +00:00
1 parent 8167a18857
commit 02b01c9deb
2 files changed
+222 -7

No files matched your search

+195
View File
@@ -235,6 +235,22 @@ describe('Aufbau', () => {
expect(picker.el.querySelector('.logo-picker-note')!.textContent).toBe('Logos werden von der Community hochgeladen.');
});
it('das Raster ist eine Liste mit role="list" (list-style: none nähme sie sonst weg); den Namen trägt die Gruppe, nicht die Liste doppelt', async () => {
await mount();
const heading = picker.el.querySelector('h3')!;
const grid = picker.el.querySelector('ul.logo-grid')!;
expect(grid.getAttribute('role')).toBe('list');
expect(grid.hasAttribute('aria-labelledby')).toBe(false);
expect(picker.el.getAttribute('role')).toBe('group');
expect(picker.el.getAttribute('aria-labelledby')).toBe(heading.id);
expect(picker.el.querySelectorAll('[aria-labelledby]')).toHaveLength(0); // unterhalb der Gruppe verweist nichts mehr auf die Überschrift
});
it('die Überschrift lässt sich per Skript fokussieren (tabindex −1), ohne in der Tab-Reihenfolge zu stehen', async () => {
await mount();
expect(picker.el.querySelector('h3')!.getAttribute('tabindex')).toBe('-1');
});
it('Umschalter „Beliebt“ (gedrückt) und „Neu“; Nicht-Admins sehen „Gemeldet“ und „Ausgeblendet“ nicht', async () => {
await mount();
expect(sortButtons().map((b) => b.textContent)).toEqual(['Beliebt', 'Neu']);
@@ -461,6 +477,133 @@ describe('Mehr laden', () => {
expect(retryButton().hidden).toBe(true);
});
describe('Fokus: ein Knopf, der unter dem Fokus verschwindet, lässt ihn nicht verloren gehen', () => {
const heading = () => picker.el.querySelector<HTMLElement>('h3')!;
it('„Mehr laden“ lädt die letzte Seite und verschwindet: der Fokus geht auf die erste neue Kachel', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b')], nextCursor: 'b' });
await mount();
moreButton().focus();
expect(document.activeElement).toBe(moreButton());
fx.listLogos.mockResolvedValue(page2());
moreButton().click();
await flush();
expect(moreButton().hidden).toBe(true);
expect(document.activeElement).toBe(tile('Logo D'));
});
it('„Mehr laden“ bleibt (es gibt noch eine Seite): es bleibt auch gewählt, während es lädt und danach', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' });
await mount();
moreButton().focus();
const slow = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
fx.listLogos.mockReturnValue(slow.promise);
moreButton().click();
// Ein gesperrter Knopf verliert den Fokus: solange er ihn hat, bleibt er bedienbar, ein zweiter Druck ist wirkungslos.
expect(moreButton().disabled).toBe(false);
expect(document.activeElement).toBe(moreButton());
moreButton().click();
expect(fx.listLogos).toHaveBeenCalledTimes(2);
slow.resolve({ logos: [logo('d')], nextCursor: 'd' });
await flush();
expect(moreButton().hidden).toBe(false);
expect(moreButton().disabled).toBe(false);
expect(document.activeElement).toBe(moreButton());
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo D']);
});
it('hat der Knopf den Fokus nicht, bleibt er während des Ladens gesperrt', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' });
await mount();
fx.listLogos.mockReturnValue(new Promise(() => {}));
moreButton().click(); // ein Mausklick im Test: der Knopf bekommt den Fokus dabei nicht
expect(document.activeElement).not.toBe(moreButton());
expect(moreButton().disabled).toBe(true);
});
it('scheitert das Nachladen unter dem Fokus, geht er auf „Nochmal versuchen“, das den Platz von „Mehr laden“ einnimmt', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' });
await mount();
moreButton().focus();
fx.listLogos.mockRejectedValueOnce(new Error('offline'));
moreButton().click();
await flush();
expect(moreButton().hidden).toBe(true);
expect(retryButton().hidden).toBe(false);
expect(document.activeElement).toBe(retryButton());
});
it('„Nochmal versuchen“ verschwindet beim Klick: der Fokus geht sofort auf die Überschrift, nicht ins Leere', async () => {
fx.listLogos.mockRejectedValueOnce(new Error('offline'));
await mount();
expect(retryButton().hidden).toBe(false);
retryButton().focus();
expect(document.activeElement).toBe(retryButton());
const slow = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
fx.listLogos.mockReturnValue(slow.promise);
retryButton().click();
expect(retryButton().hidden).toBe(true);
expect(document.activeElement).toBe(heading());
slow.resolve({ logos: [logo('a')], nextCursor: null });
await flush();
expect(names()).toEqual(['Kein Logo', 'Logo A']);
expect(document.activeElement).toBe(heading()); // niemand hat den Fokus wegbewegt: er bleibt
});
it('„Nochmal versuchen“ nach einem gescheiterten Nachladen: ebenso', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' });
await mount();
fx.listLogos.mockRejectedValueOnce(new Error('offline'));
moreButton().click();
await flush();
expect(retryButton().hidden).toBe(false);
retryButton().focus();
fx.listLogos.mockResolvedValue(page2());
retryButton().click();
await flush();
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo D', 'Logo E']);
expect(picker.el.contains(document.activeElement)).toBe(true);
expect(document.activeElement).not.toBe(document.body);
expect(document.activeElement).not.toBe(retryButton());
});
it('der Fokus wird nicht weggenommen, wenn er nicht auf dem verschwindenden Knopf lag', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b')], nextCursor: 'b' });
await mount();
tile('Logo A').focus();
fx.listLogos.mockResolvedValue(page2());
moreButton().click();
await flush();
expect(moreButton().hidden).toBe(true);
expect(document.activeElement).toBe(tile('Logo A'));
});
it('lädt die letzte Seite nur Logos, die schon da waren, geht der Fokus auf die Überschrift', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b')], nextCursor: 'b' });
await mount();
moreButton().focus();
fx.listLogos.mockResolvedValue({ logos: [logo('b')], nextCursor: null });
moreButton().click();
await flush();
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B']);
expect(document.activeElement).toBe(heading());
});
it('nach dem Aufräumen wird nichts mehr fokussiert', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' });
await mount();
moreButton().focus();
const slow = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
fx.listLogos.mockReturnValue(slow.promise);
moreButton().click();
picker.dispose();
document.body.focus();
slow.resolve(page2());
await flush();
expect(document.activeElement).toBe(document.body);
});
});
describe('der Cursor ist weg (das Logo dahinter wurde von jemandem gelöscht oder ausgeblendet)', () => {
/** Appwrite: 400 `general_cursor_not_found`. */
const cursorGone = () => Object.assign(new Error('The current cursor could not be found.'), { code: 400, type: 'general_cursor_not_found' });
@@ -1606,6 +1749,58 @@ describe('Löschen (mit Raster und Auswahl)', () => {
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo D']);
});
it('war das gelöschte Logo der Cursor und steht keins davor, beginnt die Liste von vorn (Seite 1 der Ansicht), statt „Mehr laden“ zu verlieren', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a', { ownerId: 'u1' })], nextCursor: 'a' });
await mount();
picker.select('a');
fx.listLogos.mockClear();
fx.listLogos.mockResolvedValue({ logos: [logo('x'), logo('y')], nextCursor: 'y' });
infoButton('Löschen').click();
await flush();
expect(fx.listLogos).toHaveBeenCalledExactlyOnceWith({ sort: 'popular', filter: 'all' }); // Seite 1, ohne Cursor
expect(names()).toEqual(['Kein Logo', 'Logo X', 'Logo Y']);
expect(moreButton().hidden).toBe(false); // es geht mit dem neuen Cursor weiter
fx.listLogos.mockResolvedValue({ logos: [logo('z')], nextCursor: null });
moreButton().click();
await flush();
expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all', cursor: 'y' });
});
it('dasselbe in der Admin-Ansicht „Gemeldet“: neu geladen wird mit derselben Sortierung und demselben Filter', async () => {
admin = true;
await mount();
fx.listLogos.mockResolvedValue({ logos: [logo('a', { reportCount: 2 })], nextCursor: 'a' });
sortButton('Gemeldet').click();
await flush();
picker.select('a');
fx.listLogos.mockClear();
fx.listLogos.mockResolvedValue({ logos: [logo('x', { reportCount: 1 })], nextCursor: null });
infoButton('Löschen').click();
await flush();
expect(fx.listLogos).toHaveBeenCalledExactlyOnceWith({ sort: 'new', filter: 'reported' });
expect(names()).toEqual(['Kein Logo', 'Logo X']);
expect(pressedSort()).toEqual(['Gemeldet']);
});
it('war das gelöschte Logo nicht der Cursor, oder war keine weitere Seite da, wird nichts neu geladen', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a', { ownerId: 'u1' }), logo('b')], nextCursor: 'b' });
await mount();
picker.select('a');
infoButton('Löschen').click();
await flush();
expect(fx.listLogos).toHaveBeenCalledTimes(1);
picker.dispose();
fx.listLogos.mockClear();
fx.listLogos.mockResolvedValue({ logos: [logo('c', { ownerId: 'u1' })], nextCursor: null });
await mount();
picker.select('c');
infoButton('Löschen').click();
await flush();
expect(fx.listLogos).toHaveBeenCalledTimes(1);
expect(moreButton().hidden).toBe(true);
});
it('ein anderes gelöschtes Logo lässt den Cursor für „Mehr laden“ stehen', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b', { ownerId: 'u1' }), logo('c')], nextCursor: 'c' });
await mount();