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:
1 parent
8167a18857
commit
02b01c9deb
2 files changed
+222
-7
No files matched your search
@@ -235,6 +235,22 @@ describe('Aufbau', () => {
|
|||||||
expect(picker.el.querySelector('.logo-picker-note')!.textContent).toBe('Logos werden von der Community hochgeladen.');
|
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 () => {
|
it('Umschalter „Beliebt“ (gedrückt) und „Neu“; Nicht-Admins sehen „Gemeldet“ und „Ausgeblendet“ nicht', async () => {
|
||||||
await mount();
|
await mount();
|
||||||
expect(sortButtons().map((b) => b.textContent)).toEqual(['Beliebt', 'Neu']);
|
expect(sortButtons().map((b) => b.textContent)).toEqual(['Beliebt', 'Neu']);
|
||||||
@@ -461,6 +477,133 @@ describe('Mehr laden', () => {
|
|||||||
expect(retryButton().hidden).toBe(true);
|
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)', () => {
|
describe('der Cursor ist weg (das Logo dahinter wurde von jemandem gelöscht oder ausgeblendet)', () => {
|
||||||
/** Appwrite: 400 `general_cursor_not_found`. */
|
/** 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' });
|
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']);
|
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 () => {
|
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' });
|
fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b', { ownerId: 'u1' }), logo('c')], nextCursor: 'c' });
|
||||||
await mount();
|
await mount();
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
|||||||
let order: string[] = [];
|
let order: string[] = [];
|
||||||
let nextCursor: string | null = null;
|
let nextCursor: string | null = null;
|
||||||
let loading = false;
|
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. */
|
/** Die erste Seite der aktuellen Ansicht ist da. */
|
||||||
let loaded = false;
|
let loaded = false;
|
||||||
/** Welcher Ladevorgang zuletzt scheiterte (und mit „Nochmal versuchen“ wiederholt wird). */
|
/** 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')),
|
el('span', { class: 'logo-tile-name' }, t('Kein Logo')),
|
||||||
);
|
);
|
||||||
const noneCell = el('li', { class: 'logo-cell' }, noneButton);
|
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 <ul> in manchen Screenreadern die Listensemantik. Den Namen trägt die Gruppe (root).
|
||||||
|
const grid = el('ul', { class: 'logo-grid', role: 'list' }, noneCell);
|
||||||
const emptyEl = el('p', { class: 'logo-empty' });
|
const emptyEl = el('p', { class: 'logo-empty' });
|
||||||
// Ladefehler der Liste. Immer da (leer nimmt sie keinen Platz ein), damit Screenreader den Text melden, der später hineinkommt.
|
// Ladefehler der Liste. Immer da (leer nimmt sie keinen Platz ein), damit Screenreader den Text melden, der später hineinkommt.
|
||||||
const statusEl = el('p', { class: 'auth-msg auth-msg-error logo-status', role: 'alert' });
|
const statusEl = el('p', { class: 'auth-msg auth-msg-error logo-status', role: 'alert' });
|
||||||
const retryButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-retry', hidden: true }, t('Nochmal versuchen'));
|
const retryButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-retry', hidden: true }, t('Nochmal versuchen'));
|
||||||
const moreButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-more', hidden: true }, t('Mehr laden'));
|
const moreButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-more', hidden: true }, t('Mehr laden'));
|
||||||
|
// tabindex −1: dorthin geht der Fokus, wenn „Mehr laden“ oder „Nochmal versuchen“ unter ihm verschwindet (siehe renderState).
|
||||||
|
const heading = el('h3', { id: 'logo-picker-heading', class: 'logo-picker-title', tabindex: '-1' }, t('Logo unten rechts'));
|
||||||
const root = el(
|
const root = el(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'logo-picker', role: 'group', 'aria-labelledby': 'logo-picker-heading' },
|
{ class: 'logo-picker', role: 'group', 'aria-labelledby': 'logo-picker-heading' },
|
||||||
el(
|
el(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'logo-picker-head' },
|
{ class: 'logo-picker-head' },
|
||||||
el('h3', { id: 'logo-picker-heading', class: 'logo-picker-title' }, t('Logo unten rechts')),
|
heading,
|
||||||
el('span', { class: 'logo-badge' }, t('Community')),
|
el('span', { class: 'logo-badge' }, t('Community')),
|
||||||
uploadButton,
|
uploadButton,
|
||||||
),
|
),
|
||||||
@@ -219,7 +224,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
|||||||
|
|
||||||
function createTile(logo: CommunityLogo): Tile {
|
function createTile(logo: CommunityLogo): Tile {
|
||||||
const art = el('span', { class: 'logo-tile-art' });
|
const art = el('span', { class: 'logo-tile-art' });
|
||||||
const select = el(
|
const selectButton = el(
|
||||||
'button',
|
'button',
|
||||||
{ type: 'button', class: 'tile logo-tile', 'aria-pressed': 'false', title: logo.name },
|
{ type: 'button', class: 'tile logo-tile', 'aria-pressed': 'false', title: logo.name },
|
||||||
art,
|
art,
|
||||||
@@ -229,8 +234,8 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
|||||||
const count = el('span', { class: 'logo-heart-count' });
|
const count = el('span', { class: 'logo-heart-count' });
|
||||||
const heart = el('button', { type: 'button', class: 'logo-heart', 'aria-pressed': 'false' }, icon('heart'), count);
|
const heart = el('button', { type: 'button', class: 'logo-heart', 'aria-pressed': 'false' }, icon('heart'), count);
|
||||||
const release = showLogoImage(art, logo, deps.loadLogoBlob); // das PNG auf dem dunklen Kartengrund
|
const release = showLogoImage(art, logo, deps.loadLogoBlob); // das PNG auf dem dunklen Kartengrund
|
||||||
const tile: Tile = { logo, cell: el('li', { class: 'logo-cell' }, select, heart), select, heart, count, release };
|
const tile: Tile = { logo, cell: el('li', { class: 'logo-cell' }, selectButton, heart), select: selectButton, heart, count, release };
|
||||||
select.addEventListener('click', () => handlers.onSelect(logo.id));
|
selectButton.addEventListener('click', () => handlers.onSelect(logo.id));
|
||||||
heart.addEventListener('click', () => void toggleHeart(logo.id));
|
heart.addEventListener('click', () => void toggleHeart(logo.id));
|
||||||
tiles.set(logo.id, tile);
|
tiles.set(logo.id, tile);
|
||||||
renderTile(tile);
|
renderTile(tile);
|
||||||
@@ -253,7 +258,8 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
|||||||
known.delete(id);
|
known.delete(id);
|
||||||
order = order.filter((other) => other !== id);
|
order = order.filter((other) => other !== id);
|
||||||
// „Mehr laden“ setzt hinter dem letzten geladenen Logo an (Cursor); die Abfrage braucht ein Logo, das es noch gibt.
|
// „Mehr laden“ setzt hinter dem letzten geladenen Logo an (Cursor); die Abfrage braucht ein Logo, das es noch gibt.
|
||||||
if (nextCursor === id) nextCursor = order[order.length - 1] ?? null;
|
const wasCursor = nextCursor === id;
|
||||||
|
if (wasCursor) nextCursor = order[order.length - 1] ?? null;
|
||||||
hearts.delete(id); // eine später vergebene gleiche ID erbt das Herz nicht
|
hearts.delete(id); // eine später vergebene gleiche ID erbt das Herz nicht
|
||||||
if (pinnedId === id) pinnedId = null;
|
if (pinnedId === id) pinnedId = null;
|
||||||
renderState(); // aus der letzten Kachel wird der Leer-Text
|
renderState(); // aus der letzten Kachel wird der Leer-Text
|
||||||
@@ -264,6 +270,9 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
|||||||
if (focused) noneButton.focus();
|
if (focused) noneButton.focus();
|
||||||
}
|
}
|
||||||
info.update();
|
info.update();
|
||||||
|
// Der Cursor war das einzige geladene Logo: davor gibt es keins, an dem „Mehr laden“ ansetzen könnte, und die weiteren Seiten dürfen
|
||||||
|
// nicht verloren gehen. Die Ansicht beginnt wieder bei Seite 1.
|
||||||
|
if (wasCursor && nextCursor === null) void load('reset');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Das gewählte Logo steht direkt nach „Kein Logo“, solange es nicht in der geladenen Liste ist (und seine Zeile bekannt ist). */
|
/** Das gewählte Logo steht direkt nach „Kein Logo“, solange es nicht in der geladenen Liste ist (und seine Zeile bekannt ist). */
|
||||||
@@ -297,6 +306,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
|||||||
added.push(cellOf(logo));
|
added.push(cellOf(logo));
|
||||||
}
|
}
|
||||||
grid.append(...added);
|
grid.append(...added);
|
||||||
|
firstNew ??= added[0]?.querySelector<HTMLElement>('.logo-tile') ?? null;
|
||||||
syncPin();
|
syncPin();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,15 +322,24 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
|||||||
|
|
||||||
/** Laden, „Mehr laden“, Leer-Text und Fehlerknopf nach dem aktuellen Stand. */
|
/** Laden, „Mehr laden“, Leer-Text und Fehlerknopf nach dem aktuellen Stand. */
|
||||||
function renderState(): void {
|
function renderState(): void {
|
||||||
|
const focused = document.activeElement;
|
||||||
grid.setAttribute('aria-busy', String(loading));
|
grid.setAttribute('aria-busy', String(loading));
|
||||||
moreButton.hidden = nextCursor === null || failed !== null;
|
moreButton.hidden = nextCursor === null || failed !== null;
|
||||||
moreButton.disabled = loading;
|
// Ein gesperrter Knopf verliert den Fokus (Tastatur): solange „Mehr laden“ ihn hat, bleibt es bedienbar; ein zweiter Druck ist
|
||||||
|
// wirkungslos (load).
|
||||||
|
moreButton.disabled = loading && focused !== moreButton;
|
||||||
retryButton.hidden = failed === null;
|
retryButton.hidden = failed === null;
|
||||||
let empty = '';
|
let empty = '';
|
||||||
if (loaded && order.length === 0) {
|
if (loaded && order.length === 0) {
|
||||||
empty = VIEWS[view].admin ? t('Keine Logos in dieser Ansicht.') : t('Noch keine Community-Logos – lade das erste hoch.');
|
empty = VIEWS[view].admin ? t('Keine Logos in dieser Ansicht.') : t('Noch keine Community-Logos – lade das erste hoch.');
|
||||||
}
|
}
|
||||||
emptyEl.textContent = empty;
|
emptyEl.textContent = empty;
|
||||||
|
// Verschwindet der Knopf, auf dem der Fokus lag, geht er weiter statt auf der Seite verloren zu gehen: an den Knopf, der seinen
|
||||||
|
// Platz einnimmt („Nochmal versuchen“ nach einem Fehler), sonst an die erste neue Kachel, sonst an die Überschrift.
|
||||||
|
if ((focused === moreButton && moreButton.hidden) || (focused === retryButton && retryButton.hidden)) {
|
||||||
|
const successor = [moreButton, retryButton].find((button) => !button.hidden && !button.disabled);
|
||||||
|
(successor ?? (firstNew?.isConnected ? firstNew : heading)).focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lädt die erste Seite der Ansicht neu (`reset`) oder die nächste hinten an (`more`). Nur die jüngste Antwort zählt. */
|
/** Lädt die erste Seite der Ansicht neu (`reset`) oder die nächste hinten an (`more`). Nur die jüngste Antwort zählt. */
|
||||||
@@ -328,6 +347,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
|||||||
if (mode === 'more' && (loading || nextCursor === null)) return;
|
if (mode === 'more' && (loading || nextCursor === null)) return;
|
||||||
const token = ++loadToken;
|
const token = ++loadToken;
|
||||||
const cursor = mode === 'more' ? nextCursor : null;
|
const cursor = mode === 'more' ? nextCursor : null;
|
||||||
|
firstNew = null;
|
||||||
loading = true;
|
loading = true;
|
||||||
failed = null;
|
failed = null;
|
||||||
statusEl.textContent = '';
|
statusEl.textContent = '';
|
||||||
|
|||||||
Reference in new issue
Block a user