fix(a11y): fixed saving reading progress with screen readers, closes #3864 (#3891)

This commit is contained in:
Huang Xin 2026-04-18 20:52:40 +08:00 committed by GitHub
parent 976bbcc152
commit 31e44d2e4d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 281 additions and 233 deletions

View file

@ -14,6 +14,8 @@
"build-web:vinext": "dotenv -e .env.web -- vinext build",
"start-web:vinext": "dotenv -e .env.web -- vinext start",
"build-tauri": "dotenv -e .env.tauri -- next build",
"dev-android": "tauri android build -t aarch64 -- --features devtools && adb install -r src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk",
"dev-ios": "tauri ios build -- --features devtools && ideviceinstaller -i src-tauri/gen/apple/build/arm64/Readest.ipa",
"i18n:extract": "i18next-scanner --config i18next-scanner.config.cjs",
"lint": "tsgo --noEmit && biome check .",
"test": "dotenv -e .env -e .env.test.local -- vitest",

View file

@ -1243,5 +1243,6 @@
"Remove": "إزالة",
"Edit OPDS Catalog": "تعديل كتالوج OPDS",
"Save Changes": "حفظ التغييرات",
"Use Book Layout": "استخدام تخطيط الكتاب"
"Use Book Layout": "استخدام تخطيط الكتاب",
"End of this section. Continue to the next.": "نهاية هذا القسم. تابع إلى القسم التالي."
}

View file

@ -1191,5 +1191,6 @@
"Remove": "সরান",
"Edit OPDS Catalog": "OPDS ক্যাটালগ সম্পাদনা",
"Save Changes": "পরিবর্তন সংরক্ষণ",
"Use Book Layout": "বইয়ের লেআউট ব্যবহার করুন"
"Use Book Layout": "বইয়ের লেআউট ব্যবহার করুন",
"End of this section. Continue to the next.": "এই বিভাগের শেষ। পরবর্তী বিভাগে চালিয়ে যান।"
}

View file

@ -1178,5 +1178,6 @@
"Remove": "བསུབ།",
"Edit OPDS Catalog": "OPDS དཀར་ཆག་རྩོམ་སྒྲིག",
"Save Changes": "བསྒྱུར་བཅོས་ཉར་ཚགས།",
"Use Book Layout": "དཔེ་ཆའི་བཀོད་སྒྲིག་སྤྱོད་པ"
"Use Book Layout": "དཔེ་ཆའི་བཀོད་སྒྲིག་སྤྱོད་པ",
"End of this section. Continue to the next.": "སྡེ་ཚན་འདིའི་མཇུག་རེད། རྗེས་མའི་སྡེ་ཚན་དུ་མུ་མཐུད་རོགས།"
}

View file

@ -1191,5 +1191,6 @@
"Remove": "Entfernen",
"Edit OPDS Catalog": "OPDS-Katalog bearbeiten",
"Save Changes": "Änderungen speichern",
"Use Book Layout": "Buchlayout verwenden"
"Use Book Layout": "Buchlayout verwenden",
"End of this section. Continue to the next.": "Ende dieses Abschnitts. Weiter zum nächsten."
}

View file

@ -1191,5 +1191,6 @@
"Remove": "Αφαίρεση",
"Edit OPDS Catalog": "Επεξεργασία καταλόγου OPDS",
"Save Changes": "Αποθήκευση αλλαγών",
"Use Book Layout": "Χρήση διάταξης βιβλίου"
"Use Book Layout": "Χρήση διάταξης βιβλίου",
"End of this section. Continue to the next.": "Τέλος αυτής της ενότητας. Συνεχίστε στην επόμενη."
}

View file

@ -1204,5 +1204,6 @@
"Remove": "Eliminar",
"Edit OPDS Catalog": "Editar catálogo OPDS",
"Save Changes": "Guardar cambios",
"Use Book Layout": "Usar el diseño del libro"
"Use Book Layout": "Usar el diseño del libro",
"End of this section. Continue to the next.": "Fin de esta sección. Continuar a la siguiente."
}

View file

@ -1191,5 +1191,6 @@
"Remove": "حذف",
"Edit OPDS Catalog": "ویرایش کاتالوگ OPDS",
"Save Changes": "ذخیره تغییرات",
"Use Book Layout": "استفاده از چیدمان کتاب"
"Use Book Layout": "استفاده از چیدمان کتاب",
"End of this section. Continue to the next.": "پایان این بخش. به بخش بعدی ادامه دهید."
}

View file

@ -1204,5 +1204,6 @@
"Remove": "Supprimer",
"Edit OPDS Catalog": "Modifier le catalogue OPDS",
"Save Changes": "Enregistrer les modifications",
"Use Book Layout": "Utiliser la mise en page du livre"
"Use Book Layout": "Utiliser la mise en page du livre",
"End of this section. Continue to the next.": "Fin de cette section. Continuer à la suivante."
}

View file

@ -1204,5 +1204,6 @@
"Remove": "הסר",
"Edit OPDS Catalog": "עריכת קטלוג OPDS",
"Save Changes": "שמור שינויים",
"Use Book Layout": "השתמש בפריסת הספר"
"Use Book Layout": "השתמש בפריסת הספר",
"End of this section. Continue to the next.": "סוף הסעיף הזה. המשך לסעיף הבא."
}

View file

@ -1191,5 +1191,6 @@
"Remove": "हटाएँ",
"Edit OPDS Catalog": "OPDS कैटलॉग संपादित करें",
"Save Changes": "परिवर्तन सहेजें",
"Use Book Layout": "पुस्तक का लेआउट उपयोग करें"
"Use Book Layout": "पुस्तक का लेआउट उपयोग करें",
"End of this section. Continue to the next.": "इस अनुभाग का अंत। अगले पर जारी रखें।"
}

View file

@ -1191,5 +1191,6 @@
"Remove": "Eltávolítás",
"Edit OPDS Catalog": "OPDS katalógus szerkesztése",
"Save Changes": "Módosítások mentése",
"Use Book Layout": "Könyv elrendezésének használata"
"Use Book Layout": "Könyv elrendezésének használata",
"End of this section. Continue to the next.": "Ennek a szakasznak a vége. Folytatás a következővel."
}

View file

@ -1178,5 +1178,6 @@
"Remove": "Hapus",
"Edit OPDS Catalog": "Edit Katalog OPDS",
"Save Changes": "Simpan Perubahan",
"Use Book Layout": "Gunakan tata letak buku"
"Use Book Layout": "Gunakan tata letak buku",
"End of this section. Continue to the next.": "Akhir bagian ini. Lanjutkan ke bagian berikutnya."
}

View file

@ -1204,5 +1204,6 @@
"Remove": "Rimuovi",
"Edit OPDS Catalog": "Modifica catalogo OPDS",
"Save Changes": "Salva modifiche",
"Use Book Layout": "Usa layout del libro"
"Use Book Layout": "Usa layout del libro",
"End of this section. Continue to the next.": "Fine di questa sezione. Continua alla successiva."
}

View file

@ -1178,5 +1178,6 @@
"Remove": "削除",
"Edit OPDS Catalog": "OPDSカタログを編集",
"Save Changes": "変更を保存",
"Use Book Layout": "書籍のレイアウトを使用"
"Use Book Layout": "書籍のレイアウトを使用",
"End of this section. Continue to the next.": "このセクションの終わりです。次のセクションへ進みます。"
}

View file

@ -1178,5 +1178,6 @@
"Remove": "삭제",
"Edit OPDS Catalog": "OPDS 카탈로그 편집",
"Save Changes": "변경 사항 저장",
"Use Book Layout": "책 레이아웃 사용"
"Use Book Layout": "책 레이아웃 사용",
"End of this section. Continue to the next.": "이 섹션의 끝입니다. 다음으로 계속하세요."
}

View file

@ -1178,5 +1178,6 @@
"Remove": "Alih keluar",
"Edit OPDS Catalog": "Edit Katalog OPDS",
"Save Changes": "Simpan Perubahan",
"Use Book Layout": "Guna susun atur buku"
"Use Book Layout": "Guna susun atur buku",
"End of this section. Continue to the next.": "Akhir bahagian ini. Teruskan ke bahagian seterusnya."
}

View file

@ -1191,5 +1191,6 @@
"Remove": "Verwijderen",
"Edit OPDS Catalog": "OPDS-catalogus bewerken",
"Save Changes": "Wijzigingen opslaan",
"Use Book Layout": "Boekindeling gebruiken"
"Use Book Layout": "Boekindeling gebruiken",
"End of this section. Continue to the next.": "Einde van deze sectie. Ga verder naar de volgende."
}

View file

@ -1217,5 +1217,6 @@
"Remove": "Usuń",
"Edit OPDS Catalog": "Edytuj katalog OPDS",
"Save Changes": "Zapisz zmiany",
"Use Book Layout": "Użyj układu książki"
"Use Book Layout": "Użyj układu książki",
"End of this section. Continue to the next.": "Koniec tej sekcji. Przejdź do następnej."
}

View file

@ -1204,5 +1204,6 @@
"Remove": "Remover",
"Edit OPDS Catalog": "Editar catálogo OPDS",
"Save Changes": "Salvar alterações",
"Use Book Layout": "Usar layout do livro"
"Use Book Layout": "Usar layout do livro",
"End of this section. Continue to the next.": "Fim desta secção. Continuar para a próxima."
}

View file

@ -1204,5 +1204,6 @@
"Remove": "Elimină",
"Edit OPDS Catalog": "Editare catalog OPDS",
"Save Changes": "Salvare modificări",
"Use Book Layout": "Folosește aspectul cărții"
"Use Book Layout": "Folosește aspectul cărții",
"End of this section. Continue to the next.": "Sfârșitul acestei secțiuni. Continuați la următoarea."
}

View file

@ -1217,5 +1217,6 @@
"Remove": "Удалить",
"Edit OPDS Catalog": "Редактировать каталог OPDS",
"Save Changes": "Сохранить изменения",
"Use Book Layout": "Использовать макет книги"
"Use Book Layout": "Использовать макет книги",
"End of this section. Continue to the next.": "Конец этого раздела. Перейти к следующему."
}

View file

@ -1191,5 +1191,6 @@
"Remove": "ඉවත් කරන්න",
"Edit OPDS Catalog": "OPDS නාමාවලිය සංස්කරණය",
"Save Changes": "වෙනස්කම් සුරකින්න",
"Use Book Layout": "පොතේ සැකැස්ම භාවිත කරන්න"
"Use Book Layout": "පොතේ සැකැස්ම භාවිත කරන්න",
"End of this section. Continue to the next.": "මෙම කොටසේ අවසානයයි. ඊළඟ කොටසට ඉදිරියට යන්න."
}

View file

@ -1217,5 +1217,6 @@
"Remove": "Odstrani",
"Edit OPDS Catalog": "Uredi katalog OPDS",
"Save Changes": "Shrani spremembe",
"Use Book Layout": "Uporabi postavitev knjige"
"Use Book Layout": "Uporabi postavitev knjige",
"End of this section. Continue to the next.": "Konec tega razdelka. Nadaljujte z naslednjim."
}

View file

@ -1191,5 +1191,6 @@
"Remove": "Ta bort",
"Edit OPDS Catalog": "Redigera OPDS-katalog",
"Save Changes": "Spara ändringar",
"Use Book Layout": "Använd bokens layout"
"Use Book Layout": "Använd bokens layout",
"End of this section. Continue to the next.": "Slut på det här avsnittet. Fortsätt till nästa."
}

View file

@ -1191,5 +1191,6 @@
"Remove": "நீக்கு",
"Edit OPDS Catalog": "OPDS பட்டியலைத் திருத்து",
"Save Changes": "மாற்றங்களைச் சேமி",
"Use Book Layout": "புத்தகத்தின் அமைப்பைப் பயன்படுத்து"
"Use Book Layout": "புத்தகத்தின் அமைப்பைப் பயன்படுத்து",
"End of this section. Continue to the next.": "இந்தப் பிரிவின் முடிவு. அடுத்ததற்குத் தொடரவும்."
}

View file

@ -1178,5 +1178,6 @@
"Remove": "ลบ",
"Edit OPDS Catalog": "แก้ไขแคตตาล็อก OPDS",
"Save Changes": "บันทึกการเปลี่ยนแปลง",
"Use Book Layout": "ใช้เลย์เอาต์ของหนังสือ"
"Use Book Layout": "ใช้เลย์เอาต์ของหนังสือ",
"End of this section. Continue to the next.": "สิ้นสุดส่วนนี้ ดำเนินการไปยังส่วนถัดไป"
}

View file

@ -1191,5 +1191,6 @@
"Remove": "Kaldır",
"Edit OPDS Catalog": "OPDS Kataloğunu Düzenle",
"Save Changes": "Değişiklikleri Kaydet",
"Use Book Layout": "Kitap düzenini kullan"
"Use Book Layout": "Kitap düzenini kullan",
"End of this section. Continue to the next.": "Bu bölümün sonu. Sonrakine devam edin."
}

View file

@ -1217,5 +1217,6 @@
"Remove": "Видалити",
"Edit OPDS Catalog": "Редагувати каталог OPDS",
"Save Changes": "Зберегти зміни",
"Use Book Layout": "Використовувати макет книги"
"Use Book Layout": "Використовувати макет книги",
"End of this section. Continue to the next.": "Кінець цього розділу. Продовжте до наступного."
}

View file

@ -1178,5 +1178,6 @@
"Remove": "Xóa",
"Edit OPDS Catalog": "Chỉnh sửa danh mục OPDS",
"Save Changes": "Lưu thay đổi",
"Use Book Layout": "Sử dụng bố cục sách"
"Use Book Layout": "Sử dụng bố cục sách",
"End of this section. Continue to the next.": "Kết thúc phần này. Tiếp tục đến phần tiếp theo."
}

View file

@ -1178,5 +1178,6 @@
"Remove": "移除",
"Edit OPDS Catalog": "编辑 OPDS 目录",
"Save Changes": "保存更改",
"Use Book Layout": "使用书籍排版"
"Use Book Layout": "使用书籍排版",
"End of this section. Continue to the next.": "本节结束。继续到下一节。"
}

View file

@ -1178,5 +1178,6 @@
"Remove": "移除",
"Edit OPDS Catalog": "編輯 OPDS 目錄",
"Save Changes": "儲存變更",
"Use Book Layout": "使用書籍排版"
"Use Book Layout": "使用書籍排版",
"End of this section. Continue to the next.": "本節結束。繼續到下一節。"
}

View file

@ -200,6 +200,75 @@ describe('Paginator multi-view architecture (browser)', () => {
});
});
describe('Accessibility: non-primary views are hidden from AT', () => {
const getViewWrappers = (el: Renderer) => {
const container = el.shadowRoot?.getElementById('container');
return container ? (Array.from(container.children) as HTMLElement[]) : [];
};
const wrapperContainsIframeForIndex = (wrapper: HTMLElement, doc: Document) =>
wrapper.querySelector('iframe')?.contentDocument === doc;
it('should mark non-primary view wrappers inert + aria-hidden', async () => {
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
await setupAt(firstLinear);
await waitForViews(paginator, 2);
await waitForFillComplete(paginator);
const contents = paginator.getContents();
const primary = contents.find((c) => c.index === paginator.primaryIndex);
const nonPrimary = contents.filter((c) => c.index !== paginator.primaryIndex);
expect(primary).toBeDefined();
expect(nonPrimary.length).toBeGreaterThan(0);
const wrappers = getViewWrappers(paginator);
const primaryWrapper = wrappers.find((w) => wrapperContainsIframeForIndex(w, primary!.doc));
expect(primaryWrapper).toBeDefined();
expect(primaryWrapper!.hasAttribute('inert')).toBe(false);
expect(primaryWrapper!.getAttribute('aria-hidden')).not.toBe('true');
for (const np of nonPrimary) {
const wrapper = wrappers.find((w) => wrapperContainsIframeForIndex(w, np.doc));
expect(wrapper).toBeDefined();
expect(wrapper!.hasAttribute('inert')).toBe(true);
expect(wrapper!.getAttribute('aria-hidden')).toBe('true');
}
});
it('should move inert + aria-hidden when the primary section changes', async () => {
const linearSections = book
.sections!.map((s, i) => ({ s, i }))
.filter(({ s }) => s.linear !== 'no');
expect(linearSections.length).toBeGreaterThan(1);
const first = linearSections[0]!.i;
const second = linearSections[1]!.i;
await setupAt(first);
await waitForViews(paginator, 2);
await waitForFillComplete(paginator);
await paginator.goTo({ index: second });
await new Promise((r) => setTimeout(r, 300));
expect(paginator.primaryIndex).toBe(second);
const contents = paginator.getContents();
const primary = contents.find((c) => c.index === second);
const wrappers = getViewWrappers(paginator);
const primaryWrapper = wrappers.find((w) => wrapperContainsIframeForIndex(w, primary!.doc));
expect(primaryWrapper).toBeDefined();
expect(primaryWrapper!.hasAttribute('inert')).toBe(false);
expect(primaryWrapper!.getAttribute('aria-hidden')).not.toBe('true');
const nonPrimary = contents.filter((c) => c.index !== second);
for (const np of nonPrimary) {
const wrapper = wrappers.find((w) => wrapperContainsIframeForIndex(w, np.doc));
expect(wrapper).toBeDefined();
expect(wrapper!.hasAttribute('inert')).toBe(true);
expect(wrapper!.getAttribute('aria-hidden')).toBe('true');
}
});
});
describe('Navigation between sections', () => {
it('should update primaryIndex when navigating to a different section', async () => {
const linearSections = book

View file

@ -1,61 +1,39 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, test, expect, vi, afterEach } from 'vitest';
import type { FoliateView } from '@/types/view';
vi.mock('@/utils/throttle', () => ({
throttle: vi.fn((fn: (...args: unknown[]) => unknown) => fn),
}));
vi.mock('@/utils/debounce', () => ({
debounce: vi.fn((fn: (...args: unknown[]) => unknown) => fn),
}));
const mockObserve = vi.fn();
const mockDisconnect = vi.fn();
vi.stubGlobal(
'IntersectionObserver',
class {
constructor(
public callback: IntersectionObserverCallback,
public options?: IntersectionObserverInit,
) {}
observe = mockObserve;
disconnect = mockDisconnect;
unobserve = vi.fn();
},
);
import { handleA11yNavigation } from '@/utils/a11y';
function createMockView() {
return {
renderer: {
addEventListener: vi.fn(),
},
getCFI: vi.fn().mockReturnValue('epubcfi(/6/4)'),
resolveNavigation: vi.fn().mockReturnValue({ index: 0 }),
};
return {} as FoliateView;
}
const LAST_POS_ID = 'readest-skip-link-last-pos';
const NEXT_SECTION_ID = 'readest-skip-link-next-section';
const cleanupSkipLinks = () => {
document.getElementById(LAST_POS_ID)?.remove();
document.getElementById(NEXT_SECTION_ID)?.remove();
};
const makeOptions = (overrides: Partial<Parameters<typeof handleA11yNavigation>[2]> = {}) => ({
skipToLastPosCallback: vi.fn(),
skipToLastPosLabel: 'last',
skipToNextSectionCallback: vi.fn(),
skipToNextSectionLabel: 'next',
...overrides,
});
describe('handleA11yNavigation', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockObserve.mockClear();
mockDisconnect.mockClear();
});
afterEach(() => {
consoleSpy.mockRestore();
cleanupSkipLinks();
vi.restoreAllMocks();
// Clean up skip link from document body between tests
const existing = document.getElementById('readest-skip-link');
if (existing) existing.remove();
});
test('returns early when view is null', () => {
expect(() => {
handleA11yNavigation(null, document, 0);
handleA11yNavigation(null, document);
}).not.toThrow();
expect(document.getElementById(LAST_POS_ID)).toBeNull();
expect(document.getElementById(NEXT_SECTION_ID)).toBeNull();
});
test('sets tabindex="-1" on anchor elements', () => {
@ -64,8 +42,7 @@ describe('handleA11yNavigation', () => {
document.body.appendChild(a1);
document.body.appendChild(a2);
const view = createMockView();
handleA11yNavigation(view as unknown as FoliateView, document, 0);
handleA11yNavigation(createMockView(), document);
expect(a1.getAttribute('tabindex')).toBe('-1');
expect(a2.getAttribute('tabindex')).toBe('-1');
@ -74,106 +51,88 @@ describe('handleA11yNavigation', () => {
a2.remove();
});
test('creates skip link with correct attributes', () => {
const callback = vi.fn();
const view = createMockView();
test('creates last-pos skip link with correct attributes as first child', () => {
handleA11yNavigation(
createMockView(),
document,
makeOptions({ skipToLastPosLabel: 'Skip to reading position' }),
);
handleA11yNavigation(view as unknown as FoliateView, document, 0, {
skipToLastPosCallback: callback,
skipToLastPosLabel: 'Skip to reading position',
});
const skipLink = document.getElementById('readest-skip-link');
const skipLink = document.getElementById(LAST_POS_ID);
expect(skipLink).not.toBeNull();
expect(skipLink!.getAttribute('cfi-inert')).toBe('');
expect(skipLink!.getAttribute('tabindex')).toBe('0');
expect(skipLink!.getAttribute('aria-hidden')).toBe('false');
expect(skipLink!.getAttribute('aria-label')).toBe('Skip to reading position');
// Should be first child of body
expect(document.body.firstElementChild).toBe(skipLink);
});
test('skip link click calls callback', () => {
const callback = vi.fn();
const view = createMockView();
test('creates next-section skip link with correct attributes as last child', () => {
handleA11yNavigation(
createMockView(),
document,
makeOptions({ skipToNextSectionLabel: 'Skip to next section' }),
);
handleA11yNavigation(view as unknown as FoliateView, document, 0, {
skipToLastPosCallback: callback,
skipToLastPosLabel: 'Skip',
});
const skipLink = document.getElementById('readest-skip-link');
const skipLink = document.getElementById(NEXT_SECTION_ID);
expect(skipLink).not.toBeNull();
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
skipLink!.dispatchEvent(clickEvent);
expect(callback).toHaveBeenCalledOnce();
expect(skipLink!.getAttribute('cfi-inert')).toBe('');
expect(skipLink!.getAttribute('tabindex')).toBe('0');
expect(skipLink!.getAttribute('aria-hidden')).toBe('false');
expect(skipLink!.getAttribute('aria-label')).toBe('Skip to next section');
expect(document.body.lastElementChild).toBe(skipLink);
});
test('does not duplicate skip link if already exists', () => {
const view = createMockView();
test('last-pos skip link click calls skipToLastPosCallback', () => {
const options = makeOptions();
handleA11yNavigation(createMockView(), document, options);
// First call creates the skip link
handleA11yNavigation(view as unknown as FoliateView, document, 0, {
skipToLastPosCallback: vi.fn(),
skipToLastPosLabel: 'First',
});
// Second call should not create another
handleA11yNavigation(view as unknown as FoliateView, document, 0, {
skipToLastPosCallback: vi.fn(),
skipToLastPosLabel: 'Second',
});
const skipLinks = document.querySelectorAll('#readest-skip-link');
expect(skipLinks.length).toBe(1);
// Label should still be from the first call
expect(skipLinks[0]!.getAttribute('aria-label')).toBe('First');
});
test('observes paragraph elements', () => {
const p1 = document.createElement('p');
const p2 = document.createElement('p');
const p3 = document.createElement('p');
document.body.appendChild(p1);
document.body.appendChild(p2);
document.body.appendChild(p3);
const view = createMockView();
handleA11yNavigation(view as unknown as FoliateView, document, 0);
expect(mockObserve).toHaveBeenCalledTimes(3);
expect(mockObserve).toHaveBeenCalledWith(p1);
expect(mockObserve).toHaveBeenCalledWith(p2);
expect(mockObserve).toHaveBeenCalledWith(p3);
p1.remove();
p2.remove();
p3.remove();
});
test('registers scroll and relocate listeners on renderer', () => {
const view = createMockView();
handleA11yNavigation(view as unknown as FoliateView, document, 0);
expect(view.renderer.addEventListener).toHaveBeenCalledTimes(2);
const calls = view.renderer.addEventListener.mock.calls;
expect(calls[0]![0]).toBe('scroll');
expect(typeof calls[0]![1]).toBe('function');
expect(calls[0]![2]).toEqual({ passive: true });
expect(calls[1]![0]).toBe('relocate');
expect(typeof calls[1]![1]).toBe('function');
});
test('skip link aria-label defaults to empty string when no options', () => {
const view = createMockView();
handleA11yNavigation(view as unknown as FoliateView, document, 0);
const skipLink = document.getElementById('readest-skip-link');
const skipLink = document.getElementById(LAST_POS_ID);
expect(skipLink).not.toBeNull();
expect(skipLink!.getAttribute('aria-label')).toBe('');
skipLink!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(options.skipToLastPosCallback).toHaveBeenCalledOnce();
expect(options.skipToNextSectionCallback).not.toHaveBeenCalled();
});
test('next-section skip link click calls skipToNextSectionCallback', () => {
const options = makeOptions();
handleA11yNavigation(createMockView(), document, options);
const skipLink = document.getElementById(NEXT_SECTION_ID);
expect(skipLink).not.toBeNull();
skipLink!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(options.skipToNextSectionCallback).toHaveBeenCalledOnce();
expect(options.skipToLastPosCallback).not.toHaveBeenCalled();
});
test('does not duplicate skip links if already exist', () => {
handleA11yNavigation(
createMockView(),
document,
makeOptions({ skipToLastPosLabel: 'First-last', skipToNextSectionLabel: 'First-next' }),
);
handleA11yNavigation(
createMockView(),
document,
makeOptions({ skipToLastPosLabel: 'Second-last', skipToNextSectionLabel: 'Second-next' }),
);
expect(document.querySelectorAll(`#${LAST_POS_ID}`).length).toBe(1);
expect(document.querySelectorAll(`#${NEXT_SECTION_ID}`).length).toBe(1);
expect(document.getElementById(LAST_POS_ID)!.getAttribute('aria-label')).toBe('First-last');
expect(document.getElementById(NEXT_SECTION_ID)!.getAttribute('aria-label')).toBe('First-next');
});
test('skip link aria-labels default to empty string when no options', () => {
handleA11yNavigation(createMockView(), document);
const lastPos = document.getElementById(LAST_POS_ID);
const nextSection = document.getElementById(NEXT_SECTION_ID);
expect(lastPos).not.toBeNull();
expect(nextSection).not.toBeNull();
expect(lastPos!.getAttribute('aria-label')).toBe('');
expect(nextSection!.getAttribute('aria-label')).toBe('');
});
});

View file

@ -130,10 +130,6 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook, onGoToLibr
onGoToLibrary={onGoToLibrary}
onDropdownOpenChange={(isOpen) => setDropdownOpenBook(isOpen ? bookKey : '')}
/>
<PageNavigationButtons
bookKey={bookKey}
isDropdownOpen={dropdownOpenBook === bookKey}
/>
<FoliateViewer
key={viewerKey}
bookKey={bookKey}
@ -218,6 +214,10 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook, onGoToLibr
gridInsets={gridInsets}
/>
)}
<PageNavigationButtons
bookKey={bookKey}
isDropdownOpen={dropdownOpenBook === bookKey}
/>
<Annotator bookKey={bookKey} />
<SearchResultsNav bookKey={bookKey} gridInsets={gridInsets} />
<BooknotesNav bookKey={bookKey} gridInsets={gridInsets} toc={bookDoc.toc || []} />

View file

@ -12,7 +12,7 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useCustomFontStore } from '@/store/customFontStore';
import { useParallelViewStore } from '@/store/parallelViewStore';
import { useMouseEvent, useTouchEvent, useLongPressEvent } from '../hooks/useIframeEvents';
import { usePagination } from '../hooks/usePagination';
import { usePagination, viewPagination } from '../hooks/usePagination';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { useProgressSync } from '../hooks/useProgressSync';
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
@ -196,6 +196,12 @@ const FoliateViewer: React.FC<{
}
}, [getView, getProgress, bookKey]);
const skipToNextSection = useCallback(() => {
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey);
viewPagination(view, viewSettings, 'down', 'section');
}, [bookKey]);
const docLoadHandler = (event: Event) => {
docLoaded.current = true;
if (bookDoc.rendition?.layout === 'pre-paginated') {
@ -249,9 +255,11 @@ const FoliateViewer: React.FC<{
applyScrollModeClass(detail.doc, viewSettings.scrolled || false);
applyScrollbarStyle(document, viewSettings.hideScrollbar || false);
keepTextAlignment(detail.doc);
handleA11yNavigation(viewRef.current, detail.doc, detail.index, {
handleA11yNavigation(viewRef.current, detail.doc, {
skipToLastPosCallback: skipToReadingPosition,
skipToLastPosLabel: _('Skip to last reading position'),
skipToNextSectionCallback: skipToNextSection,
skipToNextSectionLabel: _('End of this section. Continue to the next.'),
});
// Inline scripts in tauri platforms are not executed by default

View file

@ -84,15 +84,16 @@ const PageNavigationButtons: React.FC<PageNavigationButtonsProps> = ({
className={clsx(
'absolute left-2 -translate-y-1/2',
'flex items-center gap-1',
'transition-opacity duration-300',
isPageNavigationButtonsVisible
? 'top-1/2 z-10 opacity-100'
: `${appService?.isAndroidApp ? 'bottom-2' : 'pointer-events-none bottom-12'} opacity-0`,
isPageNavigationButtonsVisible ? 'top-1/2 opacity-100' : 'bottom-2 opacity-0',
!isPageNavigationButtonsVisible && !appService?.isAndroidApp ? 'pointer-events-none' : '',
)}
>
<button
onClick={handleGoLeftSection}
className='flex h-20 w-20 items-center justify-center focus:outline-none'
className={clsx(
'flex h-20 w-20 items-center justify-center focus:outline-none',
!isPageNavigationButtonsVisible && appService?.isAndroidApp && 'h-4 w-4',
)}
aria-hidden={false}
aria-label={getLeftSectionLabel()}
tabIndex={0}
@ -132,10 +133,8 @@ const PageNavigationButtons: React.FC<PageNavigationButtonsProps> = ({
className={clsx(
'absolute right-2 -translate-y-1/2',
'flex items-center gap-1',
'transition-opacity duration-300',
isPageNavigationButtonsVisible
? 'top-1/2 z-10 opacity-100'
: `${appService?.isAndroidApp ? 'bottom-2' : 'pointer-events-none bottom-12'} opacity-0`,
isPageNavigationButtonsVisible ? 'top-1/2 opacity-100' : 'bottom-2 opacity-0',
!isPageNavigationButtonsVisible && !appService?.isAndroidApp ? 'pointer-events-none' : '',
)}
>
<button
@ -158,7 +157,10 @@ const PageNavigationButtons: React.FC<PageNavigationButtonsProps> = ({
</button>
<button
onClick={handleGoRightSection}
className='flex h-20 w-20 items-center justify-center focus:outline-none'
className={clsx(
'flex h-20 w-20 items-center justify-center focus:outline-none',
!isPageNavigationButtonsVisible && appService?.isAndroidApp && 'h-4 w-4',
)}
aria-hidden={false}
aria-label={getRightSectionLabel()}
tabIndex={0}

View file

@ -1,73 +1,27 @@
import { FoliateView } from '@/types/view';
import { throttle } from './throttle';
import { debounce } from './debounce';
export const handleA11yNavigation = (
view: FoliateView | null,
document: Document,
index: number,
options?: { skipToLastPosCallback: () => void; skipToLastPosLabel: string },
options?: {
skipToLastPosCallback: () => void;
skipToLastPosLabel: string;
skipToNextSectionCallback: () => void;
skipToNextSectionLabel: string;
},
) => {
if (!view) return;
const state = {
skipInitial: true,
hasRecentRelocate: false,
relocateTimer: null as ReturnType<typeof setTimeout> | null,
};
const markRelocateEnd = debounce(() => {
state.hasRecentRelocate = false;
}, 2000);
const markRelocated = () => {
state.hasRecentRelocate = true;
markRelocateEnd();
};
const throttledMarkRelocated = throttle(markRelocated, 1000);
view.renderer.addEventListener('scroll', throttledMarkRelocated, { passive: true });
view.renderer.addEventListener('relocate', throttledMarkRelocated);
const observer = new IntersectionObserver(
(entries) => {
if (state.skipInitial) {
state.skipInitial = false;
return;
}
if (state.hasRecentRelocate) return;
for (const entry of entries) {
if (entry.isIntersecting) {
const range = document.createRange();
range.selectNodeContents(entry.target);
const cfi = view.getCFI(index, range);
setTimeout(() => {
if (state.hasRecentRelocate) return;
const resolved = view.resolveNavigation(cfi);
view.renderer.goTo?.(resolved);
console.log('Navigating to new location from screen reader');
}, 500);
break;
}
}
},
{ threshold: 0 },
);
document.querySelectorAll('a').forEach((el) => {
el.setAttribute('tabindex', '-1');
});
document.querySelectorAll('p').forEach((el) => {
observer.observe(el);
});
// Inject a hidden "skip to reading position" link as the very first accessible
// element in the iframe body. NVDA's D-key landmark navigation fires no DOM
// events, so we cannot detect it; instead, when NVDA enters the landmark its
// virtual cursor lands on this link first. The user presses Enter to jump to
// their actual reading position.
const skipLinkId = 'readest-skip-link';
const skipLinkId = 'readest-skip-link-last-pos';
if (document.body && !document.getElementById(skipLinkId)) {
const skipLink = document.createElement('div');
skipLink.id = skipLinkId;
@ -90,4 +44,27 @@ export const handleA11yNavigation = (
});
document.body.prepend(skipLink);
}
const skipNextSectionLinkId = 'readest-skip-link-next-section';
if (document.body && !document.getElementById(skipNextSectionLinkId)) {
const skipLink = document.createElement('div');
skipLink.id = skipNextSectionLinkId;
skipLink.setAttribute('cfi-inert', '');
skipLink.setAttribute('tabindex', '0');
skipLink.setAttribute('aria-hidden', 'false');
skipLink.setAttribute('aria-label', options?.skipToNextSectionLabel ?? '');
Object.assign(skipLink.style, {
position: 'relative',
left: '0px',
top: 'auto',
width: '1px',
height: '1px',
overflow: 'hidden',
});
skipLink.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
options?.skipToNextSectionCallback();
});
document.body.appendChild(skipLink);
}
};

@ -1 +1 @@
Subproject commit 0eaf426fb2321747f7a46839419f0f75924e4529
Subproject commit 7657c78bd8d2e88de35b10374ad1ecd006f1baae