fix: make the tag and address book row buttons and New tag full-size buttons with a touch hit area, and name each tag button after its tag

This commit is contained in:
Linus Rath
2026-10-28 10:44:18 +01:00
parent 8b2b77c349
commit 5abc4a5334
33 changed files with 108 additions and 56 deletions
@@ -89,6 +89,17 @@ describe('KeywordSettings', () => {
expect(useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red')).toBeUndefined();
});
// A list of "Edit" / "Delete" with nothing else said which tag a screen
// reader user was on; each button is named after its tag now, as a full
// size icon button (32px, 44px hit area on touch).
it('names each tag row button after its tag', () => {
render(<KeywordSettings />);
expect(screen.getAllByRole('button', { name: 'edit_tag' })).toHaveLength(DEFAULT_KEYWORDS.length);
const deletes = screen.getAllByRole('button', { name: 'delete_tag' });
expect(deletes).toHaveLength(DEFAULT_KEYWORDS.length);
expect(deletes[0].className).toContain('h-8 w-8');
});
it('shows edit form when edit button clicked', () => {
render(<KeywordSettings />);
const editButtons = screen.getAllByTitle('edit');
@@ -45,7 +45,8 @@ describe('deleting a tag', () => {
render(<KeywordSettings />);
const second = DEFAULT_KEYWORDS[1];
fireEvent.click(screen.getAllByRole('button', { name: 'delete' })[1]);
// Each Delete is named after its tag (the mocked t returns the key).
fireEvent.click(screen.getAllByRole('button', { name: 'delete_tag' })[1]);
expect(useSettingsStore.getState().emailKeywords.map((k) => k.id)).not.toContain(second.id);
expect(screen.queryByRole('alertdialog')).toBeNull();
@@ -9,6 +9,7 @@ import { useManagedAccountStore } from "@/stores/managed-account-store";
import { toast } from "@/stores/toast-store";
import { SettingsSection } from "./settings-section";
import { cn } from "@/lib/utils";
import { IconButton } from "@/components/ui/icon-button";
import type { AddressBook, AddressBookRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "./share-collection-dialog";
import { PromptDialog } from "@/components/ui/prompt-dialog";
@@ -207,10 +208,10 @@ export function AddressBookManagementSettings() {
>
<Book className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{book.name}</span>
<span className="text-sm font-medium truncate block" title={book.name} dir="auto">{book.name}</span>
</div>
{book.isDefault && (
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
<span className="flex-shrink-0 text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
{t("default")}
</span>
)}
@@ -227,49 +228,31 @@ export function AddressBookManagementSettings() {
</span>
);
})()}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100 transition-opacity">
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100 transition-opacity">
{canRename && (
<button
type="button"
onClick={() => setEditingId(book.id)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t("rename")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<IconButton label={t("rename")} onClick={() => setEditingId(book.id)} className="text-muted-foreground">
<Pencil />
</IconButton>
)}
{canSetDefault && (
<button
type="button"
onClick={() => handleSetDefault(book)}
disabled={isLoading}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
title={t("set_default")}
>
<Star className="w-3.5 h-3.5" />
</button>
<IconButton label={t("set_default")} onClick={() => handleSetDefault(book)} disabled={isLoading} className="text-muted-foreground">
<Star />
</IconButton>
)}
{!book.isShared && book.myRights?.mayShare && (
<button
type="button"
onClick={() => setSharingId(book.id)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t("share")}
>
<Users className="w-3.5 h-3.5" />
</button>
<IconButton label={t("share")} onClick={() => setSharingId(book.id)} className="text-muted-foreground">
<Users />
</IconButton>
)}
{canDelete && (
<button
type="button"
<IconButton
label={t("delete")}
onClick={() => void handleDelete(book)}
disabled={isLoading}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
title={t("delete")}
aria-label={t("delete")}
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<Trash2 />
</IconButton>
)}
</div>
</div>
+19 -20
View File
@@ -16,6 +16,8 @@ import { useEmailStore } from "@/stores/email-store";
import { useFilterStore } from "@/stores/filter-store";
import { SettingsSection, SettingItem, ToggleSwitch, Select, AdvancedOnly } from './settings-section';
import { Plus, Pencil, Trash2, GripVertical, Check, X, Loader2, Search } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { cn } from "@/lib/utils";
import { KEYWORD_PREFIX } from "@/lib/thread-utils";
import { findUnrecognizedKeywords, type UnrecognizedKeyword } from "@/lib/keyword-discovery";
@@ -93,6 +95,7 @@ function KeywordRow({
}) {
const t = useTranslations("settings.keywords");
const hasChildren = hasChildKeywords(keyword.id, keywords);
const tagLabel = keyword.label || keyword.id;
// Measured with the prefix attached, since that is what occupies the column.
const keywordCandidates = (nestedTags ? keywordRenderings(keywordLevels(keyword.id)) : [keyword.id])
.map((rendering) => KEYWORD_PREFIX + rendering);
@@ -134,24 +137,19 @@ function KeywordRow({
ariaLabel={t("visibility_field")}
className="text-xs py-1"
/>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100 transition-opacity">
<button
type="button"
onClick={onEdit}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t("edit")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
type="button"
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100 transition-opacity">
<IconButton label={t("edit_tag", { name: tagLabel })} title={t("edit")} onClick={onEdit} className="text-muted-foreground">
<Pencil />
</IconButton>
<IconButton
label={t("delete_tag", { name: tagLabel })}
title={hasChildren ? t("has_children_delete") : t("delete")}
onClick={onDelete}
disabled={hasChildren}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
title={hasChildren ? t("has_children_delete") : t("delete")}
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<Trash2 />
</IconButton>
</div>
</div>
);
@@ -727,17 +725,18 @@ export function KeywordSettings() {
/>
) : (
<div className="flex items-center gap-2 pt-1">
<button
type="button"
<Button
variant="outline"
size="sm"
onClick={() => {
setIsAdding(true);
setEditingId(null);
}}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md border border-dashed border-border hover:border-primary hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
className="border-dashed text-muted-foreground hover:text-foreground"
>
<Plus className="w-3.5 h-3.5" />
<Plus />
{t("add_keyword")}
</button>
</Button>
</div>
)}
</div>
+2
View File
@@ -1353,6 +1353,8 @@
"id_exists": "معرّف الوسم هذا موجود بالفعل",
"edit": "تعديل الوسم",
"delete": "حذف الوسم",
"edit_tag": "تعديل {name}",
"delete_tag": "حذف {name}",
"deleted": "تم حذف الوسم",
"save": "حفظ",
"add": "إضافة",
+2
View File
@@ -1353,6 +1353,8 @@
"id_exists": "Aquest ID d'etiqueta ja existeix",
"edit": "Edita l'etiqueta",
"delete": "Suprimeix l'etiqueta",
"edit_tag": "Edita {name}",
"delete_tag": "Suprimeix {name}",
"deleted": "Etiqueta suprimida",
"save": "Desa",
"add": "Afegeix",
+2
View File
@@ -1350,6 +1350,8 @@
"id_exists": "Toto ID štítku již existuje",
"edit": "Upravit štítek",
"delete": "Odstranit štítek",
"edit_tag": "Upravit {name}",
"delete_tag": "Smazat {name}",
"deleted": "Štítek byl odstraněn",
"save": "Uložit",
"add": "Přidat",
+2
View File
@@ -1353,6 +1353,8 @@
"id_exists": "Dette tag-ID findes allerede",
"edit": "Redigér tag",
"delete": "Slet tag",
"edit_tag": "Rediger {name}",
"delete_tag": "Slet {name}",
"deleted": "Tag slettet",
"save": "Gem",
"add": "Tilføj",
+2
View File
@@ -1350,6 +1350,8 @@
"id_exists": "Diese Label-ID existiert bereits",
"edit": "Label bearbeiten",
"delete": "Label löschen",
"edit_tag": "{name} bearbeiten",
"delete_tag": "{name} löschen",
"deleted": "Label gelöscht",
"save": "Speichern",
"add": "Hinzufügen",
+2
View File
@@ -1353,6 +1353,8 @@
"id_exists": "This tag ID already exists",
"edit": "Edit tag",
"delete": "Delete tag",
"edit_tag": "Edit {name}",
"delete_tag": "Delete {name}",
"deleted": "Tag deleted",
"save": "Save",
"add": "Add",
+2
View File
@@ -1350,6 +1350,8 @@
"id_exists": "Este ID de etiqueta ya existe",
"edit": "Editar etiqueta",
"delete": "Eliminar etiqueta",
"edit_tag": "Editar {name}",
"delete_tag": "Eliminar {name}",
"deleted": "Etiqueta eliminada",
"save": "Guardar",
"add": "Añadir",
+2
View File
@@ -1353,6 +1353,8 @@
"id_exists": "See sildi ID on juba kasutusel",
"edit": "Muuda silti",
"delete": "Kustuta silt",
"edit_tag": "Muuda {name}",
"delete_tag": "Kustuta {name}",
"deleted": "Silt kustutatud",
"save": "Salvesta",
"add": "Lisa",
+2
View File
@@ -1353,6 +1353,8 @@
"id_exists": "این شناسه قبلاً وجود دارد",
"edit": "ویرایش برچسب",
"delete": "حذف برچسب",
"edit_tag": "ویرایش {name}",
"delete_tag": "حذف {name}",
"deleted": "برچسب حذف شد",
"save": "ذخیره",
"add": "افزودن",
+2
View File
@@ -1350,6 +1350,8 @@
"id_exists": "Cet ID d'étiquette existe déjà",
"edit": "Éditer l'étiquette",
"delete": "Supprimer l'étiquette",
"edit_tag": "Modifier {name}",
"delete_tag": "Supprimer {name}",
"deleted": "Étiquette supprimée",
"save": "Enregistrer",
"add": "Ajouter",
+2
View File
@@ -1315,6 +1315,8 @@
"id_exists": "מזהה מילת מפתח זה כבר קיים",
"edit": "ערוך מילת מפתח",
"delete": "מחק מילת מפתח",
"edit_tag": "עריכת {name}",
"delete_tag": "מחיקת {name}",
"deleted": "מילת המפתח נמחקה",
"save": "שמור",
"add": "לְהוֹסִיף",
+2
View File
@@ -1353,6 +1353,8 @@
"id_exists": "Ez a címke azonosító már létezik",
"edit": "Címke szerkesztése",
"delete": "Címke törlése",
"edit_tag": "{name} szerkesztése",
"delete_tag": "{name} törlése",
"deleted": "Címke törölve",
"save": "Mentés",
"add": "Hozzáadás",
+2
View File
@@ -1350,6 +1350,8 @@
"id_exists": "Questo ID etichetta esiste già",
"edit": "Modifica etichetta",
"delete": "Elimina etichetta",
"edit_tag": "Modifica {name}",
"delete_tag": "Elimina {name}",
"deleted": "Etichetta eliminata",
"save": "Salva",
"add": "Aggiungi",
+2
View File
@@ -1350,6 +1350,8 @@
"id_exists": "このラベルIDは既に存在します",
"edit": "ラベルを編集",
"delete": "ラベルを削除",
"edit_tag": "{name} を編集",
"delete_tag": "{name} を削除",
"deleted": "ラベルを削除しました",
"save": "保存",
"add": "追加",
+2
View File
@@ -1350,6 +1350,8 @@
"id_exists": "이 태그 ID는 이미 존재합니다",
"edit": "태그 편집",
"delete": "태그 삭제",
"edit_tag": "{name} 편집",
"delete_tag": "{name} 삭제",
"deleted": "태그가 삭제되었어요",
"save": "저장",
"add": "추가",
+2
View File
@@ -1350,6 +1350,8 @@
"id_exists": "Šāds taga identifikators jau pastāv",
"edit": "Rediģēt tagu",
"delete": "Dzēst tagu",
"edit_tag": "Rediģēt {name}",
"delete_tag": "Dzēst {name}",
"deleted": "Tags izdzēsts",
"save": "Saglabāt",
"add": "Pievienot",
+2
View File
@@ -1353,6 +1353,8 @@
"id_exists": "Энэ хаягийн ID аль хэдийн байна",
"edit": "Таг засах",
"delete": "Тагыг устгах",
"edit_tag": "{name}-г засах",
"delete_tag": "{name}-г устгах",
"deleted": "Таг устгасан",
"save": "Хадгалах",
"add": "Нэмэх",
+2
View File
@@ -1353,6 +1353,8 @@
"id_exists": "Denne etikett-ID-en finnes allerede",
"edit": "Rediger etikett",
"delete": "Slett etikett",
"edit_tag": "Rediger {name}",
"delete_tag": "Slett {name}",
"deleted": "Etikett slettet",
"save": "Lagre",
"add": "Legg til",
+2
View File
@@ -1350,6 +1350,8 @@
"id_exists": "Deze label-ID bestaat al",
"edit": "Label bewerken",
"delete": "Label verwijderen",
"edit_tag": "{name} bewerken",
"delete_tag": "{name} verwijderen",
"deleted": "Label verwijderd",
"save": "Opslaan",
"add": "Toevoegen",
+2
View File
@@ -1350,6 +1350,8 @@
"id_exists": "Ten ID etykiety już istnieje",
"edit": "Edytuj etykietę",
"delete": "Usuń etykietę",
"edit_tag": "Edytuj {name}",
"delete_tag": "Usuń {name}",
"deleted": "Etykieta usunięta",
"save": "Zapisz",
"add": "Dodaj",
+2
View File
@@ -1350,6 +1350,8 @@
"id_exists": "Este ID de etiqueta já existe",
"edit": "Editar etiqueta",
"delete": "Eliminar etiqueta",
"edit_tag": "Editar {name}",
"delete_tag": "Excluir {name}",
"deleted": "Etiqueta eliminada",
"save": "Salvar",
"add": "Adicionar",
+2
View File
@@ -1353,6 +1353,8 @@
"id_exists": "Această etichetă ID există deja",
"edit": "Editați eticheta",
"delete": "Șterge eticheta",
"edit_tag": "Editați {name}",
"delete_tag": "Ștergeți {name}",
"deleted": "Eticheta a fost ștearsă",
"save": "Salvare",
"add": "Adăugați",
+2
View File
@@ -1350,6 +1350,8 @@
"id_exists": "Этот идентификатор тега уже существует",
"edit": "Редактировать тег",
"delete": "Удалить тег",
"edit_tag": "Изменить «{name}»",
"delete_tag": "Удалить «{name}»",
"deleted": "Тег удалён",
"save": "Сохранить",
"add": "Добавить",
+2
View File
@@ -1353,6 +1353,8 @@
"id_exists": "Toto ID štítku už existuje",
"edit": "Upraviť štítok",
"delete": "Odstrániť štítok",
"edit_tag": "Upraviť {name}",
"delete_tag": "Zmazať {name}",
"deleted": "Štítok bol odstránený",
"save": "Uložiť",
"add": "Pridať",
+2
View File
@@ -3534,6 +3534,8 @@
"id_exists": "รหัสแท็กนี้มีอยู่แล้ว",
"edit": "แก้ไขแท็ก",
"delete": "ลบแท็ก",
"edit_tag": "แก้ไข {name}",
"delete_tag": "ลบ {name}",
"deleted": "ลบแท็กแล้ว",
"save": "บันทึก",
"add": "เพิ่ม",
+2
View File
@@ -1350,6 +1350,8 @@
"id_exists": "Bu etiket kimliği zaten mevcut",
"edit": "Etiketi düzenle",
"delete": "Etiketi sil",
"edit_tag": "{name} düzenle",
"delete_tag": "{name} sil",
"deleted": "Etiket silindi",
"save": "Kaydet",
"add": "Ekle",
+2
View File
@@ -1350,6 +1350,8 @@
"id_exists": "Цей ідентифікатор ключового слова вже існує",
"edit": "Редагувати ключове слово",
"delete": "Видалити ключове слово",
"edit_tag": "Редагувати «{name}»",
"delete_tag": "Видалити «{name}»",
"deleted": "Ключове слово видалено",
"save": "зберегти",
"add": "додати",
+2
View File
@@ -1353,6 +1353,8 @@
"id_exists": "此標籤 ID 已存在",
"edit": "編輯標籤",
"delete": "刪除標籤",
"edit_tag": "編輯 {name}",
"delete_tag": "刪除 {name}",
"deleted": "標籤已刪除",
"save": "儲存",
"add": "新增",
+2
View File
@@ -1350,6 +1350,8 @@
"id_exists": "此标签ID已存在",
"edit": "编辑标签",
"delete": "删除标签",
"edit_tag": "编辑 {name}",
"delete_tag": "删除 {name}",
"deleted": "标签已删除",
"save": "保存",
"add": "添加",