fix: name the tag or person on each row's delete and remove-access button

This commit is contained in:
Linus Rath
2026-10-31 13:44:54 +01:00
parent e7fd665bb4
commit d6b9b342e9
6 changed files with 67 additions and 7 deletions
+4
View File
@@ -278,6 +278,9 @@
"login_changed": "Sie haben das Konto gewechselt, daher wurde die Regel nicht gespeichert" "login_changed": "Sie haben das Konto gewechselt, daher wurde die Regel nicht gespeichert"
} }
}, },
"delegates": {
"remove_for": "Zugriff für {name} entfernen"
},
"device_sync": { "device_sync": {
"auth_problem_text": "Der Server akzeptiert die Anmeldung von {account} nicht mehr, daher werden die Kontakte und Kalender dieses Kontos auf diesem Gerät nicht mehr synchronisiert. Melden Sie sich erneut an, um fortzufahren. Auf diesem Gerät vorgenommene Änderungen bleiben erhalten.", "auth_problem_text": "Der Server akzeptiert die Anmeldung von {account} nicht mehr, daher werden die Kontakte und Kalender dieses Kontos auf diesem Gerät nicht mehr synchronisiert. Melden Sie sich erneut an, um fortzufahren. Auf diesem Gerät vorgenommene Änderungen bleiben erhalten.",
"auth_problem_title": "Erneut anmelden, um weiter zu synchronisieren", "auth_problem_title": "Erneut anmelden, um weiter zu synchronisieren",
@@ -533,6 +536,7 @@
"share_no_users": "Keine anderen Benutzer gefunden", "share_no_users": "Keine anderen Benutzer gefunden",
"share_remove": "Zugriff entfernen", "share_remove": "Zugriff entfernen",
"share_remove_confirm": "Zugriff entfernen?", "share_remove_confirm": "Zugriff entfernen?",
"share_remove_for": "Zugriff für {name} entfernen",
"share_role_custom": "Benutzerdefiniert", "share_role_custom": "Benutzerdefiniert",
"share_search_placeholder": "Nach Name oder E-Mail suchen", "share_search_placeholder": "Nach Name oder E-Mail suchen",
"share_unavailable": "Teilen ist auf diesem Gerät nicht verfügbar", "share_unavailable": "Teilen ist auf diesem Gerät nicht verfügbar",
+4
View File
@@ -325,6 +325,9 @@
"deep_link": { "deep_link": {
"open_failed": "Couldn't open this" "open_failed": "Couldn't open this"
}, },
"delegates": {
"remove_for": "Remove access for {name}"
},
"device_sync": { "device_sync": {
"auth_problem_text": "The server no longer accepts the sign-in of {account}, so its contacts and calendars on this device stopped syncing. Sign in again to resume. Changes made on this device are kept.", "auth_problem_text": "The server no longer accepts the sign-in of {account}, so its contacts and calendars on this device stopped syncing. Sign in again to resume. Changes made on this device are kept.",
"auth_problem_title": "Sign in again to keep syncing", "auth_problem_title": "Sign in again to keep syncing",
@@ -730,6 +733,7 @@
"share_no_users": "No other users found", "share_no_users": "No other users found",
"share_remove": "Remove access", "share_remove": "Remove access",
"share_remove_confirm": "Remove access?", "share_remove_confirm": "Remove access?",
"share_remove_for": "Remove access for {name}",
"share_role_custom": "Custom", "share_role_custom": "Custom",
"share_search_placeholder": "Search by name or email", "share_search_placeholder": "Search by name or email",
"share_unavailable": "Sharing is not available on this device", "share_unavailable": "Sharing is not available on this device",
@@ -0,0 +1,31 @@
// Buttons repeated on every row of a list read the same to a screen reader:
// TalkBack heard "Delete tag" on each tag in Settings > Tags and "Remove
// access" on each person a file is shared with, without saying whose. They
// carry the row's name now (webmail keyword-settings.tsx: "Delete {name}").
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
const SRC = path.resolve(__dirname, '../..');
const read = (rel: string) => fs.readFileSync(path.join(SRC, rel), 'utf8').split('\r\n').join('\n');
describe('per-row buttons', () => {
it('name the tag they edit or delete', () => {
const source = read('components/settings/KeywordSettings.tsx');
expect(source).toContain("t('settings.keywords.delete_tag', 'Delete {name}', { name: kw.label })");
expect(source).toContain("t('settings.keywords.edit_tag', 'Edit {name}', { name: kw.label })");
expect(source).not.toMatch(/accessibilityLabel=\{t\('settings\.keywords\.(delete|edit)',/);
});
it('name the person whose file access they remove', () => {
const source = read('components/files/ShareSheet.tsx');
expect(source).toContain("t('files.share_remove_for', 'Remove access for {name}', { name: principalName(principalId) })");
expect(source).not.toMatch(/accessibilityLabel=\{t\('files\.share_remove', 'Remove access'\)\}/);
});
it('name the delegate they change or remove', () => {
const source = read('components/settings/DelegateSettings.tsx');
expect(source).toContain("accessibilityLabel={t('delegates.remove_for', 'Remove access for {name}', { name: display.name })}");
expect(source).toContain("accessibilityLabel={t('delegates.dialog_title_change', 'Change access for {name}', { name: display.name })}");
});
});
+10 -3
View File
@@ -167,15 +167,21 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
? t('sharing.directory_not_browsable', "Your server doesn't let you browse its user directory.") ? t('sharing.directory_not_browsable', "Your server doesn't let you browse its user directory.")
: null; : null;
const renderPrincipalLabel = (principalId: string) => { const principalName = (principalId: string) => {
const p = principalsById.get(principalId); const p = principalsById.get(principalId);
// Stalwart 1.0 with directory queries off hides every principal the user // Stalwart 1.0 with directory queries off hides every principal the user
// has no relationship with, so a sharee may have no name. // has no relationship with, so a sharee may have no name.
const unresolved = !p && !loadingPrincipals; const unresolved = !p && !loadingPrincipals;
return p?.description || p?.name || (unresolved ? t('sharing.unknown_user', 'Unknown user') : principalId);
};
const renderPrincipalLabel = (principalId: string) => {
const p = principalsById.get(principalId);
const unresolved = !p && !loadingPrincipals;
return ( return (
<View style={styles.principalInfo}> <View style={styles.principalInfo}>
<Text style={styles.principalName} numberOfLines={1}> <Text style={styles.principalName} numberOfLines={1}>
{p?.description || p?.name || (unresolved ? t('sharing.unknown_user', 'Unknown user') : principalId)} {principalName(principalId)}
</Text> </Text>
{p?.email || unresolved ? ( {p?.email || unresolved ? (
<Text style={styles.principalEmail} numberOfLines={1}> <Text style={styles.principalEmail} numberOfLines={1}>
@@ -257,7 +263,8 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
)} )}
hitSlop={8} hitSlop={8}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={t('files.share_remove', 'Remove access')} // One per person: the name tells the buttons apart.
accessibilityLabel={t('files.share_remove_for', 'Remove access for {name}', { name: principalName(principalId) })}
> >
<Trash2 size={18} color={c.error} /> <Trash2 size={18} color={c.error} />
</Pressable> </Pressable>
+16 -2
View File
@@ -241,10 +241,24 @@ export function DelegateSettings() {
</View> </View>
</View> </View>
<View style={styles.rowButtons}> <View style={styles.rowButtons}>
<Button variant="outline" size="sm" onPress={() => setDialog({ mode: 'change', summary })} disabled={busy || incomplete}> {/* Named with the person: every row has the same two buttons. */}
<Button
variant="outline"
size="sm"
onPress={() => setDialog({ mode: 'change', summary })}
disabled={busy || incomplete}
accessibilityLabel={t('delegates.dialog_title_change', 'Change access for {name}', { name: display.name })}
>
{t('delegates.change', 'Change')} {t('delegates.change', 'Change')}
</Button> </Button>
<Button variant="outline" size="sm" onPress={() => confirmRemove(summary)} disabled={incomplete} loading={busy}> <Button
variant="outline"
size="sm"
onPress={() => confirmRemove(summary)}
disabled={incomplete}
loading={busy}
accessibilityLabel={t('delegates.remove_for', 'Remove access for {name}', { name: display.name })}
>
{t('delegates.remove', 'Remove access')} {t('delegates.remove', 'Remove access')}
</Button> </Button>
</View> </View>
+2 -2
View File
@@ -120,10 +120,10 @@ export function KeywordSettings() {
<Text style={styles.kwLabel}>{kw.label}</Text> <Text style={styles.kwLabel}>{kw.label}</Text>
<Text style={styles.kwId}>$label:{kw.id}</Text> <Text style={styles.kwId}>$label:{kw.id}</Text>
<View style={{ flexDirection: 'row', gap: 2 }}> <View style={{ flexDirection: 'row', gap: 2 }}>
<Pressable style={styles.iconBtn} onPress={() => setEditingId(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.edit', "Edit tag")}> <Pressable style={styles.iconBtn} onPress={() => setEditingId(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.edit_tag', 'Edit {name}', { name: kw.label })}>
<Pencil size={14} color={c.mutedForeground} /> <Pencil size={14} color={c.mutedForeground} />
</Pressable> </Pressable>
<Pressable style={styles.iconBtn} onPress={() => deleteKeyword(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.delete', "Delete tag")}> <Pressable style={styles.iconBtn} onPress={() => deleteKeyword(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.delete_tag', 'Delete {name}', { name: kw.label })}>
<Trash2 size={14} color={c.mutedForeground} /> <Trash2 size={14} color={c.mutedForeground} />
</Pressable> </Pressable>
</View> </View>