diff --git a/lib/__tests__/plugin-ui-kit-a11y.test.tsx b/lib/__tests__/plugin-ui-kit-a11y.test.tsx new file mode 100644 index 000000000..d36cae07a --- /dev/null +++ b/lib/__tests__/plugin-ui-kit-a11y.test.tsx @@ -0,0 +1,57 @@ +import { readdirSync, statSync } from 'node:fs'; +import path from 'node:path'; +import { render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +// The kit's fields tell a screen reader about their hint, error and +// required state, and the kit's own button names follow the app language +// instead of being English (or "×") everywhere. + +const kit = await import('../plugin-sandbox/ui-kit'); +const { KIT_STRINGS, kitString } = await import('../plugin-sandbox/ui-kit/kit-strings'); + +const setLocale = (locale: string | undefined) => { + (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = locale; +}; + +describe('plugin UI kit accessibility', () => { + afterEach(() => setLocale(undefined)); + + it('reads a Field\'s hint with its control', () => { + render(); + const input = screen.getByLabelText('Title'); + expect(input).toHaveAccessibleDescription('Shown in Paperless'); + expect(input).not.toHaveAttribute('aria-invalid'); + }); + + it('marks a Field with an error invalid and reads the error', () => { + render(); + const input = screen.getByRole('textbox', { name: 'Title' }); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(input).toHaveAttribute('aria-required', 'true'); + expect(input).toHaveAccessibleDescription('Title is missing'); + }); + + it('names the clear button of a search box in the app language', () => { + setLocale('de'); + render( {}} label="Search" />); + expect(screen.getByRole('button', { name: 'Suche leeren' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '×' })).toBeNull(); + }); + + it('falls back from a regional locale to its language, then English', () => { + setLocale('de-AT'); + expect(kitString('clear')).toBe('Leeren'); + setLocale('xx'); + expect(kitString('remove_named', { name: 'Apple' })).toBe('Remove Apple'); + }); + + it('has every kit string in every app locale', () => { + const dir = path.resolve(__dirname, '../../locales'); + const locales = readdirSync(dir).filter((d) => statSync(path.join(dir, d)).isDirectory() && !d.startsWith('_')); + const keys = Object.keys(KIT_STRINGS.en).sort(); + for (const locale of locales) { + expect(Object.keys(KIT_STRINGS[locale] ?? {}).sort(), locale).toEqual(keys); + } + }); +}); diff --git a/lib/plugin-sandbox/ui-kit/components.tsx b/lib/plugin-sandbox/ui-kit/components.tsx index 656c80ef7..cf8add32a 100644 --- a/lib/plugin-sandbox/ui-kit/components.tsx +++ b/lib/plugin-sandbox/ui-kit/components.tsx @@ -28,6 +28,7 @@ import { closeFloatingDropdown, registerFloatingDropdownListener, } from './floating-dropdown-bridge'; +import { kitString } from './kit-strings'; type Children = { children?: React.ReactNode }; type Tone = 'neutral' | 'info' | 'success' | 'warning' | 'danger' | 'primary'; @@ -523,7 +524,7 @@ export function Combobox({ e.stopPropagation(); toggleMultipleOption(opt); }} - aria-label={`Remove ${opt.label}`} + aria-label={kitString('remove_named', { name: String(opt.label) })} className="text-muted-foreground hover:text-foreground p-0.5 rounded focus:outline-none" > @@ -583,7 +584,7 @@ export function Combobox({ e.stopPropagation(); }} onClick={handleClear} - aria-label="Clear" + aria-label={kitString('clear')} className="p-1 rounded hover:text-foreground focus:outline-none" > @@ -598,7 +599,7 @@ export function Combobox({ e.stopPropagation(); }} onClick={handleToggle} - aria-label={isOpen ? 'Close' : 'Open'} + aria-label={isOpen ? kitString('close') : kitString('open')} className="p-1 rounded hover:text-foreground focus:outline-none disabled:opacity-50" > {isOpen ? : } @@ -753,17 +754,30 @@ export function Segmented({ value, options, onChange, label }: { value: string; */ export function Field({ label, hint, error, required = false, children }: { label: React.ReactNode; hint?: React.ReactNode; error?: React.ReactNode; required?: boolean } & Children) { const autoId = React.useId(); - const only = React.Children.count(children) === 1 && React.isValidElement(children) ? children as React.ReactElement<{ id?: string }> : null; + type ControlProps = { id?: string; 'aria-describedby'?: string; 'aria-invalid'?: boolean; 'aria-required'?: boolean }; + const only = React.Children.count(children) === 1 && React.isValidElement(children) ? children as React.ReactElement : null; const controlId = only?.props.id ?? autoId; + // The hint or error is read with the field, an error marks it invalid, + // and the asterisk is said as "required". + const noteId = `${autoId}-note`; + const note = error || hint; + const control = only + ? React.cloneElement(only, { + id: controlId, + 'aria-describedby': [only.props['aria-describedby'], note ? noteId : null].filter(Boolean).join(' ') || undefined, + ...(error ? { 'aria-invalid': true } : {}), + ...(required ? { 'aria-required': true } : {}), + }) + : children; return ( {label} - {required && *} + {required && *} - {only && !only.props.id ? React.cloneElement(only, { id: controlId }) : children} - {hint && !error && {hint}} - {error && {error}} + {control} + {hint && !error && {hint}} + {error && {error}} ); } @@ -782,7 +796,7 @@ export function SearchInput({ value, onChange, placeholder, label, className }: className="w-full h-8 rounded-md border border-input bg-background ps-8 pe-8 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring [&::-webkit-search-cancel-button]:hidden" /> {value && ( - onChange('')} aria-label="×" className="absolute end-1.5 top-1/2 -translate-y-1/2 p-1 rounded text-muted-foreground hover:text-foreground"> + onChange('')} aria-label={kitString('clear_search')} className="absolute end-1.5 top-1/2 -translate-y-1/2 p-1 rounded text-muted-foreground hover:text-foreground"> )} @@ -1144,7 +1158,7 @@ export function Chip({ icon, label, detail, onClick, onRemove, removeLabel, titl {body} {onRemove && ( - + )} diff --git a/lib/plugin-sandbox/ui-kit/kit-strings.ts b/lib/plugin-sandbox/ui-kit/kit-strings.ts new file mode 100644 index 000000000..2d58fefcf --- /dev/null +++ b/lib/plugin-sandbox/ui-kit/kit-strings.ts @@ -0,0 +1,50 @@ +// The kit's own accessible names ("Clear", "Remove …"). The kit runs in the +// plugin iframe, which has no copy of the app's catalog, so its few strings +// live here, one table per app locale. kit-strings.test.ts checks that every +// locale in locales/ has every key. + +type KitKey = 'remove' | 'remove_named' | 'clear' | 'clear_search' | 'open' | 'close'; + +export const KIT_STRINGS: Record> = { + en: { remove: 'Remove', remove_named: 'Remove {name}', clear: 'Clear', clear_search: 'Clear search', open: 'Open list', close: 'Close list' }, + de: { remove: 'Entfernen', remove_named: '{name} entfernen', clear: 'Leeren', clear_search: 'Suche leeren', open: 'Liste öffnen', close: 'Liste schließen' }, + ar: { remove: 'إزالة', remove_named: 'إزالة {name}', clear: 'مسح', clear_search: 'مسح البحث', open: 'فتح القائمة', close: 'إغلاق القائمة' }, + ca: { remove: 'Treu', remove_named: 'Treu {name}', clear: 'Esborra', clear_search: 'Esborra la cerca', open: 'Obre la llista', close: 'Tanca la llista' }, + cs: { remove: 'Odebrat', remove_named: 'Odebrat {name}', clear: 'Vymazat', clear_search: 'Vymazat hledání', open: 'Otevřít seznam', close: 'Zavřít seznam' }, + da: { remove: 'Fjern', remove_named: 'Fjern {name}', clear: 'Ryd', clear_search: 'Ryd søgning', open: 'Åbn liste', close: 'Luk liste' }, + es: { remove: 'Quitar', remove_named: 'Quitar {name}', clear: 'Borrar', clear_search: 'Borrar búsqueda', open: 'Abrir lista', close: 'Cerrar lista' }, + et: { remove: 'Eemalda', remove_named: 'Eemalda {name}', clear: 'Tühjenda', clear_search: 'Tühjenda otsing', open: 'Ava loend', close: 'Sulge loend' }, + fa: { remove: 'حذف', remove_named: 'حذف {name}', clear: 'پاک کردن', clear_search: 'پاک کردن جستجو', open: 'باز کردن فهرست', close: 'بستن فهرست' }, + fr: { remove: 'Retirer', remove_named: 'Retirer {name}', clear: 'Effacer', clear_search: 'Effacer la recherche', open: 'Ouvrir la liste', close: 'Fermer la liste' }, + he: { remove: 'הסרה', remove_named: 'הסרת {name}', clear: 'ניקוי', clear_search: 'ניקוי החיפוש', open: 'פתיחת הרשימה', close: 'סגירת הרשימה' }, + hu: { remove: 'Eltávolítás', remove_named: '{name} eltávolítása', clear: 'Törlés', clear_search: 'Keresés törlése', open: 'Lista megnyitása', close: 'Lista bezárása' }, + it: { remove: 'Rimuovi', remove_named: 'Rimuovi {name}', clear: 'Cancella', clear_search: 'Cancella la ricerca', open: 'Apri elenco', close: 'Chiudi elenco' }, + ja: { remove: '削除', remove_named: '{name} を削除', clear: 'クリア', clear_search: '検索をクリア', open: '一覧を開く', close: '一覧を閉じる' }, + ko: { remove: '삭제', remove_named: '{name} 삭제', clear: '지우기', clear_search: '검색 지우기', open: '목록 열기', close: '목록 닫기' }, + lv: { remove: 'Noņemt', remove_named: 'Noņemt {name}', clear: 'Notīrīt', clear_search: 'Notīrīt meklēšanu', open: 'Atvērt sarakstu', close: 'Aizvērt sarakstu' }, + mn: { remove: 'Хасах', remove_named: '{name}-г хасах', clear: 'Цэвэрлэх', clear_search: 'Хайлтыг цэвэрлэх', open: 'Жагсаалт нээх', close: 'Жагсаалт хаах' }, + nb: { remove: 'Fjern', remove_named: 'Fjern {name}', clear: 'Tøm', clear_search: 'Tøm søket', open: 'Åpne listen', close: 'Lukk listen' }, + nl: { remove: 'Verwijderen', remove_named: '{name} verwijderen', clear: 'Wissen', clear_search: 'Zoekopdracht wissen', open: 'Lijst openen', close: 'Lijst sluiten' }, + pl: { remove: 'Usuń', remove_named: 'Usuń {name}', clear: 'Wyczyść', clear_search: 'Wyczyść wyszukiwanie', open: 'Otwórz listę', close: 'Zamknij listę' }, + pt: { remove: 'Remover', remove_named: 'Remover {name}', clear: 'Limpar', clear_search: 'Limpar pesquisa', open: 'Abrir lista', close: 'Fechar lista' }, + ro: { remove: 'Elimină', remove_named: 'Elimină {name}', clear: 'Golește', clear_search: 'Golește căutarea', open: 'Deschide lista', close: 'Închide lista' }, + ru: { remove: 'Удалить', remove_named: 'Удалить {name}', clear: 'Очистить', clear_search: 'Очистить поиск', open: 'Открыть список', close: 'Закрыть список' }, + sk: { remove: 'Odstrániť', remove_named: 'Odstrániť {name}', clear: 'Vymazať', clear_search: 'Vymazať hľadanie', open: 'Otvoriť zoznam', close: 'Zavrieť zoznam' }, + th: { remove: 'นำออก', remove_named: 'นำ {name} ออก', clear: 'ล้าง', clear_search: 'ล้างการค้นหา', open: 'เปิดรายการ', close: 'ปิดรายการ' }, + tr: { remove: 'Kaldır', remove_named: '{name} öğesini kaldır', clear: 'Temizle', clear_search: 'Aramayı temizle', open: 'Listeyi aç', close: 'Listeyi kapat' }, + uk: { remove: 'Вилучити', remove_named: 'Вилучити {name}', clear: 'Очистити', clear_search: 'Очистити пошук', open: 'Відкрити список', close: 'Закрити список' }, + zh: { remove: '移除', remove_named: '移除 {name}', clear: '清除', clear_search: '清除搜索', open: '打开列表', close: '关闭列表' }, + 'zh-TW': { remove: '移除', remove_named: '移除 {name}', clear: '清除', clear_search: '清除搜尋', open: '開啟清單', close: '關閉清單' }, +}; + +/** The app language the runtime set (applyLocale in runtime.tsx), else English. */ +function kitLocale(): string { + return (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en'; +} + +/** A kit string in the app's language: the exact locale, then its language, then English. */ +export function kitString(key: KitKey, vars: Record = {}): string { + const locale = kitLocale(); + const table = KIT_STRINGS[locale] ?? KIT_STRINGS[locale.split('-')[0]] ?? KIT_STRINGS.en; + return (table[key] ?? KIT_STRINGS.en[key]).replace(/\{(\w+)\}/g, (m, name: string) => vars[name] ?? m); +}
{hint}
{error}