mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
fix: name the plugin kit's buttons in the app language and read field hints and errors with the field
This commit is contained in:
@@ -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(<kit.Field label="Title" hint="Shown in Paperless"><kit.Input /></kit.Field>);
|
||||
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(<kit.Field label="Title" error="Title is missing" required><kit.Input /></kit.Field>);
|
||||
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(<kit.SearchInput value="x" onChange={() => {}} 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
>
|
||||
<X className="w-3 h-3" aria-hidden="true" />
|
||||
@@ -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"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
@@ -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 ? <ChevronUp className="w-3.5 h-3.5" aria-hidden="true" /> : <ChevronDown className="w-3.5 h-3.5" aria-hidden="true" />}
|
||||
@@ -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<ControlProps> : 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 (
|
||||
<div className="bk">
|
||||
<label htmlFor={only ? controlId : undefined} className="text-sm font-medium mb-1 block text-foreground">
|
||||
{label}
|
||||
{required && <span className="text-destructive"> *</span>}
|
||||
{required && <span className="text-destructive" aria-hidden="true"> *</span>}
|
||||
</label>
|
||||
{only && !only.props.id ? React.cloneElement(only, { id: controlId }) : children}
|
||||
{hint && !error && <p className="text-xs text-muted-foreground mt-1">{hint}</p>}
|
||||
{error && <p className="text-sm text-destructive mt-1" role="alert">{error}</p>}
|
||||
{control}
|
||||
{hint && !error && <p id={noteId} className="text-xs text-muted-foreground mt-1">{hint}</p>}
|
||||
{error && <p id={noteId} className="text-sm text-destructive mt-1" role="alert">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
<button type="button" onClick={() => onChange('')} aria-label="×" className="absolute end-1.5 top-1/2 -translate-y-1/2 p-1 rounded text-muted-foreground hover:text-foreground">
|
||||
<button type="button" onClick={() => 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">
|
||||
<X className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
@@ -1144,7 +1158,7 @@ export function Chip({ icon, label, detail, onClick, onRemove, removeLabel, titl
|
||||
<span className={box} title={title}>
|
||||
{body}
|
||||
{onRemove && (
|
||||
<button type="button" onClick={onRemove} aria-label={removeLabel ?? '×'} className="p-0.5 -me-1 rounded hover:bg-accent transition-colors">
|
||||
<button type="button" onClick={onRemove} aria-label={removeLabel ?? (typeof label === 'string' ? kitString('remove_named', { name: label }) : kitString('remove'))} className="p-0.5 -me-1 rounded hover:bg-accent transition-colors">
|
||||
<X className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -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<string, Record<KitKey, string>> = {
|
||||
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, string> = {}): 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);
|
||||
}
|
||||
Reference in New Issue
Block a user