mirror of
https://github.com/readest/readest
synced 2026-04-21 13:37:44 +00:00
This commit is contained in:
parent
976bbcc152
commit
31e44d2e4d
39 changed files with 281 additions and 233 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.": "نهاية هذا القسم. تابع إلى القسم التالي."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.": "এই বিভাগের শেষ। পরবর্তী বিভাগে চালিয়ে যান।"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.": "སྡེ་ཚན་འདིའི་མཇུག་རེད། རྗེས་མའི་སྡེ་ཚན་དུ་མུ་མཐུད་རོགས།"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.": "Τέλος αυτής της ενότητας. Συνεχίστε στην επόμενη."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.": "پایان این بخش. به بخش بعدی ادامه دهید."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.": "סוף הסעיף הזה. המשך לסעיף הבא."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.": "इस अनुभाग का अंत। अगले पर जारी रखें।"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.": "このセクションの終わりです。次のセクションへ進みます。"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.": "이 섹션의 끝입니다. 다음으로 계속하세요."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.": "Конец этого раздела. Перейти к следующему."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.": "මෙම කොටසේ අවසානයයි. ඊළඟ කොටසට ඉදිරියට යන්න."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.": "இந்தப் பிரிவின் முடிவு. அடுத்ததற்குத் தொடரவும்."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.": "สิ้นสุดส่วนนี้ ดำเนินการไปยังส่วนถัดไป"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.": "Кінець цього розділу. Продовжте до наступного."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.": "本节结束。继续到下一节。"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.": "本節結束。繼續到下一節。"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 || []} />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in a new issue