diff --git a/web/src/logos/logo-picker.test.ts b/web/src/logos/logo-picker.test.ts index 01689ce..fc8865f 100644 --- a/web/src/logos/logo-picker.test.ts +++ b/web/src/logos/logo-picker.test.ts @@ -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('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(); diff --git a/web/src/logos/logo-picker.ts b/web/src/logos/logo-picker.ts index a1cd088..b855910 100644 --- a/web/src/logos/logo-picker.ts +++ b/web/src/logos/logo-picker.ts @@ -109,6 +109,8 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void } let order: string[] = []; let nextCursor: string | null = null; let loading = false; + /** Die erste Kachel, die der jüngste Ladevorgang angehängt hat: dorthin geht der Fokus, wenn „Mehr laden“ danach verschwindet. */ + let firstNew: HTMLElement | null = null; /** Die erste Seite der aktuellen Ansicht ist da. */ let loaded = false; /** Welcher Ladevorgang zuletzt scheiterte (und mit „Nochmal versuchen“ wiederholt wird). */ @@ -169,19 +171,22 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void } el('span', { class: 'logo-tile-name' }, t('Kein Logo')), ); const noneCell = el('li', { class: 'logo-cell' }, noneButton); - const grid = el('ul', { class: 'logo-grid', 'aria-labelledby': 'logo-picker-heading' }, noneCell); + // role="list": list-style: none nimmt dem