fix: give chip remove buttons, tree expanders, task circles and month events a 24px target

This commit is contained in:
Linus Rath
2026-10-29 19:38:45 +01:00
parent 9f6c7b99c9
commit f43eb9f41a
12 changed files with 40 additions and 17 deletions
@@ -104,7 +104,8 @@ describe('CalendarMonthView tasks', () => {
// its day's cell, below the 30px date header.
const thu = chipSlot('thu');
expect(dayColumn(thu)).toBe(3);
expect(thu.style.top).toBe('52px');
// Rows are 26px apart: 24px chips, big enough to hit (WCAG 2.5.8).
expect(thu.style.top).toBe('56px');
const fri = chipSlot('fri');
expect(dayColumn(fri)).toBe(4);
expect(fri.style.top).toBe('30px');
@@ -118,15 +119,15 @@ describe('CalendarMonthView tasks', () => {
onOpenDay,
});
const row = document.querySelector<HTMLElement>('[data-week="2026-09-07"]');
// jsdom measures no viewport, so rows keep the 130px minimum: room for
// four 22px rows under the 30 + 4 offset, the last taken by the link -
// jsdom measures no viewport, so rows keep the 146px minimum: room for
// four 26px rows under the 30 + 4 offset, the last taken by the link -
// three entries, not one, on a short desktop window.
expect(row?.style.minHeight).toBe('130px');
expect(row?.style.minHeight).toBe('146px');
expect(screen.getByText('Event e1')).toBeInTheDocument();
expect(document.querySelector('[data-calendar-task="b"]')).not.toBeNull();
expect(document.querySelector('[data-calendar-task="c"]')).toBeNull();
const more = screen.getByRole('button', { name: 'events.more' });
expect(more.parentElement?.style.top).toBe(`${30 + 66}px`);
expect(more.parentElement?.style.top).toBe(`${30 + 78}px`);
fireEvent.click(more);
expect(onOpenDay).toHaveBeenCalledWith(new Date(2026, 8, 10));
});
+1 -1
View File
@@ -462,7 +462,7 @@ export function CalendarDayView({
<div className="space-y-0.5">
{/* The same chip as the month and week grids. */}
{dayTasks.map((task) => (
<div key={task.id} className={isMobile ? undefined : "h-5"}>
<div key={task.id} className={isMobile ? undefined : "h-6"}>
<CalendarTaskChip
task={task}
calendar={calendars.find((c) => task.calendarIds[c.id])}
+2 -1
View File
@@ -102,7 +102,8 @@ export function CalendarMonthView({
// into full entries via "Show time in month view" (#666).
const showChips = !isMobile || showTimeInMonthView;
const overlayTop = isMobile ? 34 : 30;
const rowHeight = isMobile ? 18 : 22;
// 24px chips with a mouse (WCAG 2.5.8); touch screens show dots.
const rowHeight = isMobile ? 18 : 26;
const chipHeight = rowHeight - 2;
// A wide-screen week row fits at least three entries and the "N more"
// link, so a short window (1280x720) does not cut every day to one.
+1 -1
View File
@@ -225,7 +225,7 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
<button
type="button"
onClick={() => onRemove(p.email)}
className="flex-shrink-0 p-0.5 rounded-full hover:bg-muted-foreground/20 transition-colors min-w-[20px] min-h-[20px] flex items-center justify-center"
className="flex-shrink-0 p-0.5 rounded-full hover:bg-muted-foreground/20 transition-colors min-w-6 min-h-6 -m-0.5 flex items-center justify-center"
aria-label={`${t("remove")} ${p.name || p.email}`}
>
<X className="w-3 h-3" />
+2 -1
View File
@@ -45,7 +45,8 @@ export function CalendarTaskChip({ task, calendar, onToggleComplete, onSelect, t
onClick={(e) => { e.stopPropagation(); onToggleComplete?.(task); }}
className={cn(
"group flex items-center flex-shrink-0",
touch ? "self-stretch w-11 justify-center" : "h-full ps-1 pe-0.5",
// 24px wide, so the circle keeps a 24px target next to the title.
touch ? "self-stretch w-11 justify-center" : "h-full w-6 justify-center",
)}
aria-label={isCompleted ? t("tasks.mark_incomplete") : t("tasks.mark_complete")}
>
@@ -33,6 +33,13 @@ describe('TagBadge', () => {
expect(onRemove).toHaveBeenCalledOnce();
});
it('gives the remove button a 24px target: a 12px icon and 6px padding all round', () => {
render(<TagBadge tagId="work" variant="badge" onRemove={() => {}} />);
const remove = screen.getByRole('button', { name: 'remove_tag' });
expect(remove).toHaveClass('p-1.5');
expect(remove.querySelector('svg')).toHaveClass('w-3', 'h-3');
});
it('leaves the dot alone, having nowhere to put the control', () => {
render(<TagBadge tagId="work" variant="dot" onRemove={() => {}} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
+2 -2
View File
@@ -4603,7 +4603,7 @@ export function EmailComposer({
)}
<button
onClick={() => removeAttachment(index)}
className={cn("ms-1 hover:text-destructive min-w-[20px] min-h-[20px] flex items-center justify-center", TOUCH_HIT_AREA)}
className={cn("ms-0.5 -my-0.5 hover:text-destructive min-w-6 min-h-6 flex items-center justify-center", TOUCH_HIT_AREA)}
title={att.uploading ? t('upload_cancel') : t('recipient_remove')}
aria-label={att.uploading ? t('upload_cancel') : `${t('recipient_remove')} ${att.name}`}
>
@@ -5722,7 +5722,7 @@ export function RecipientChipInput({
)}
<button
type="button"
className={cn("flex items-center justify-center w-4 h-4 rounded-full hover:bg-muted-foreground/20 transition-colors", TOUCH_HIT_AREA)}
className={cn("flex items-center justify-center w-6 h-6 -m-1 rounded-full hover:bg-muted-foreground/20 transition-colors", TOUCH_HIT_AREA)}
aria-label={isEditing ? t('recipient_remove') : t('recipient_remove_named', { name: recipientDisplayName(chip) })}
onClick={(e) => {
e.stopPropagation();
+2 -1
View File
@@ -108,7 +108,8 @@ export function TagBadge({
<button
type="button"
onClick={onRemove}
className="ms-0.5 shrink-0 rounded-full p-0.5 hover:bg-black/10 dark:hover:bg-white/10"
// 24px to hit, drawn as before: the margins give the padding back.
className="-my-1 -me-1 shrink-0 rounded-full p-1.5 hover:bg-black/10 dark:hover:bg-white/10"
title={t("remove_tag")}
aria-label={t("remove_tag")}
>
+2 -2
View File
@@ -216,7 +216,7 @@ export function FilesSidebar({
type="button"
onClick={() => toggleExpanded(folder.id)}
aria-expanded={isOpen}
className="p-0.5 -ms-1 rounded hover:bg-foreground/10 text-muted-foreground shrink-0"
className="flex items-center justify-center size-6 -ms-[7px] -me-[3px] -my-[3px] rounded hover:bg-foreground/10 text-muted-foreground shrink-0"
aria-label={isOpen ? t("collapse_folder", { name: folder.name }) : t("expand_folder", { name: folder.name })}
>
{isOpen ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
@@ -316,7 +316,7 @@ export function FilesSidebar({
type="button"
onClick={() => toggleSection("tree")}
aria-expanded={!collapsed.tree}
className="p-0.5 -ms-1 rounded hover:bg-foreground/10 text-muted-foreground shrink-0"
className="flex items-center justify-center size-6 -ms-[7px] -me-[3px] -my-[3px] rounded hover:bg-foreground/10 text-muted-foreground shrink-0"
aria-label={collapsed.tree ? t("expand_folder", { name: t("my_files") }) : t("collapse_folder", { name: t("my_files") })}
>
{collapsed.tree ? <ChevronRight className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
@@ -82,4 +82,14 @@ describe('folder list on touch', () => {
expect(toggle.className).toContain('pointer-coarse:after:min-h-11');
expect(toggle.getAttribute('aria-label')).toBeTruthy();
});
it('gives the chevron a 24px target with a mouse too, in its 20px slot', () => {
useSettingsStore.setState({ hideAccountSwitcher: true } as never);
const withChild = [...mailboxes, { ...box('trips', 'Trips', null), parentId: 'travel' } as Mailbox];
render(<Sidebar mailboxes={withChild} selectedMailbox="inbox" />);
const toggle = row('travel').querySelector<HTMLElement>('[data-testid="folder-expand-toggle"]')!;
expect(toggle.style.width).toBe('24px');
expect(toggle.style.height).toBe('24px');
expect(toggle.style.margin).toBe('-2px');
});
});
+4 -2
View File
@@ -286,7 +286,7 @@ function SidebarRowCounts({
e.stopPropagation();
onUnreadClick();
}}
className={cn(unreadClass, "cursor-pointer hover:underline rounded-sm focus-visible:outline-hidden")}
className={cn(unreadClass, "inline-flex items-center justify-center min-w-6 min-h-6 -my-1 cursor-pointer hover:underline rounded-sm focus-visible:outline-hidden")}
title={t('unread_filter', { count: unreadCount })}
aria-label={t('unread_filter', { count: unreadCount })}
>
@@ -467,7 +467,9 @@ function SidebarRow({
aria-label={isExpanded ? tShell('collapse_folder', { name: label }) : tShell('expand_folder', { name: label })}
aria-expanded={isExpanded}
className={cn("flex items-center justify-center rounded hover:bg-muted active:bg-accent transition-colors", TOUCH_HIT_AREA)}
style={{ width: CHEVRON_SLOT, height: CHEVRON_SLOT }}
// 24px to hit (WCAG 2.5.8) in the 20px slot: the negative
// margin gives the extra 2px on each side back.
style={{ width: 24, height: 24, margin: (CHEVRON_SLOT - 24) / 2 }}
title={isExpanded ? t('collapse_tooltip') : t('expand_tooltip')}
>
{isExpanded ? (
+1 -1
View File
@@ -90,7 +90,7 @@ export function SearchChips({
<button
type="button"
onClick={() => onRemoveFilter(chip.key)}
className="ms-0.5 p-0.5 rounded-full hover:bg-primary/20 transition-colors"
className="-my-1 -me-1 p-1.5 rounded-full hover:bg-primary/20 transition-colors"
>
<X className="w-3 h-3" />
</button>