feat: tick a row from anywhere beside its avatar or checkbox #1381

This commit is contained in:
Linus Rath
2026-10-29 20:53:52 +01:00
parent 50b3ddccec
commit c0fb427eec
2 changed files with 208 additions and 82 deletions
@@ -436,6 +436,65 @@ describe('ThreadListItem whole row toggles in selection mode', () => {
});
});
describe('ThreadListItem avatar gutter (#1381)', () => {
function setup(emails: Email[]) {
useSettingsStore.setState({ interfaceLayout: 'default', mailLayout: 'split', density: 'regular' } as never);
useEmailStore.setState({ selectedEmailKeys: new Set<string>(), selectedMailbox: 'inbox' });
const onEmailSelect = vi.fn();
const onEmailDoubleClick = vi.fn();
const onToggleExpand = vi.fn();
const [thread] = groupEmailsByThread(emails);
const utils = render(
<ThreadListItem
thread={thread}
isExpanded={false}
onToggleExpand={onToggleExpand}
onEmailSelect={onEmailSelect}
onEmailDoubleClick={onEmailDoubleClick}
/>,
);
const gutter = utils.container.querySelector<HTMLElement>('[data-select-gutter]')!;
return { ...utils, gutter, onEmailSelect, onEmailDoubleClick, onToggleExpand };
}
const selection = () => [...useEmailStore.getState().selectedEmailKeys].sort();
it('ticks a message from beside its avatar instead of opening it', () => {
const { gutter, onEmailSelect } = setup([makeEmail()]);
fireEvent.click(gutter);
expect(onEmailSelect).not.toHaveBeenCalled();
expect(selection()).toEqual(['email-1']);
fireEvent.click(gutter);
expect(selection()).toEqual([]);
});
it('does not open the message on a double click there', () => {
const { gutter, onEmailDoubleClick } = setup([makeEmail()]);
fireEvent.doubleClick(gutter);
expect(onEmailDoubleClick).not.toHaveBeenCalled();
});
it('ticks a thread from beside its avatar, and its chevron still only unfolds it', () => {
const { gutter, container, onEmailSelect, onToggleExpand } = setup([
makeEmail({ id: 'older', receivedAt: '2024-01-14T10:00:00Z' }),
makeEmail({ id: 'newer', receivedAt: '2024-01-15T10:00:00Z' }),
]);
fireEvent.click(gutter);
expect(onEmailSelect).not.toHaveBeenCalled();
expect(selection()).toEqual(['newer', 'older']);
fireEvent.click(container.querySelector('[data-expand-toggle]')!);
expect(onToggleExpand).toHaveBeenCalledTimes(1);
expect(selection()).toEqual(['newer', 'older']);
});
it('ticks the row once from the avatar itself', () => {
const { onEmailSelect } = setup([makeEmail()]);
fireEvent.click(screen.getByRole('checkbox'));
expect(onEmailSelect).not.toHaveBeenCalled();
expect(selection()).toEqual(['email-1']);
});
});
describe('ThreadListItem Mountain View phone row layout', () => {
it('sets the star at the end of the preview line, not under the time', () => {
useSettingsStore.setState({ interfaceLayout: 'mountain-view', mailLayout: 'split', showPreview: true, density: 'regular' } as never);
@@ -695,6 +754,22 @@ describe('ThreadListItem Mountain View desktop row', () => {
useEmailStore.setState({ selectedEmailKeys: new Set<string>(), selectedMailbox: 'inbox' });
});
it('ticks the row from its checkbox without opening it, even on a double click (#1381)', () => {
const onEmailSelect = vi.fn();
const onEmailDoubleClick = vi.fn();
const [thread] = groupEmailsByThread([makeEmail()]);
render(
<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} onEmailSelect={onEmailSelect} onEmailDoubleClick={onEmailDoubleClick} />,
);
const checkbox = screen.getByRole('checkbox');
fireEvent.click(checkbox);
fireEvent.click(checkbox);
fireEvent.doubleClick(checkbox);
expect(onEmailSelect).not.toHaveBeenCalled();
expect(onEmailDoubleClick).not.toHaveBeenCalled();
expect(useEmailStore.getState().selectedEmailKeys.size).toBe(0);
});
it('runs the snippet on from the subject after a dash', () => {
const { container } = renderRow(makeEmail({ preview: 'See you there' }));
expect(container.textContent).toContain('Test Subject - See you there');
+133 -82
View File
@@ -135,10 +135,48 @@ function mvHoverBackground(checked: boolean, unread: boolean, selected: boolean)
return unread ? "bg-background" : "bg-[var(--mv-row-read)]";
}
/**
* The strip the sender avatar sits in, from the row's leading edge to the
* text and over the row's full height. A click anywhere in it ticks the row
* instead of opening it, so a slightly missed avatar doesn't open the mail
* (#1381). It is for pointers only: the avatar inside stays the checkbox
* keyboards and screen readers use.
*/
function AvatarGutter({
onToggle,
children,
}: {
onToggle: (e: React.MouseEvent) => void;
children: React.ReactNode;
}) {
return (
<div
data-select-gutter=""
onClick={(e) => {
e.stopPropagation();
onToggle(e);
}}
// Two quick clicks tick and untick; they must not open the message too.
onDoubleClick={(e) => e.stopPropagation()}
// Hovering the strip shows the avatar's check, as hovering the avatar does.
className="group/select -ms-3 flex flex-shrink-0 cursor-pointer items-center self-stretch ps-3"
style={{
marginBlock: 'calc(var(--density-item-py) * -1)',
paddingBlock: 'var(--density-item-py)',
marginInlineEnd: 'calc(var(--density-item-gap) * -1)',
paddingInlineEnd: 'var(--density-item-gap)',
}}
>
{children}
</div>
);
}
/**
* Mountain View's leading controls: a checkbox and a star, both always
* visible and both acting on the row without opening it. They take the slot
* the default layout gives to the sender avatar.
* the default layout gives to the sender avatar. The checkbox reaches from
* the row's leading edge over its full height, as in Gmail (#1381).
*/
function MvRowLead({
checked,
@@ -156,43 +194,52 @@ function MvRowLead({
starLabel: string;
}) {
return (
<div className="flex h-10 items-center gap-0.5 flex-shrink-0 self-start">
<div className="flex flex-shrink-0 items-start self-stretch">
<button
type="button"
role="checkbox"
aria-checked={checked}
aria-label={selectLabel}
onClick={onToggleChecked}
className={cn(
"p-1 rounded-full transition-colors hover:bg-foreground/10",
checked && "text-primary"
)}
onDoubleClick={(e) => e.stopPropagation()}
className="group/check -ms-3 flex items-start self-stretch ps-3 pe-0.5"
>
{checked ? (
<CheckSquare className="w-[18px] h-[18px]" />
) : (
<Square className="w-[18px] h-[18px] text-muted-foreground" />
)}
<span className="flex h-10 items-center">
<span
className={cn(
"p-1 rounded-full transition-colors group-hover/check:bg-foreground/10",
checked && "text-primary"
)}
>
{checked ? (
<CheckSquare className="w-[18px] h-[18px]" />
) : (
<Square className="w-[18px] h-[18px] text-muted-foreground" />
)}
</span>
</span>
</button>
{onToggleStar && (
<button
type="button"
aria-label={starLabel}
aria-pressed={starred}
title={starLabel}
onClick={(e) => {
e.stopPropagation();
onToggleStar();
}}
className="p-1 rounded-full transition-colors hover:bg-foreground/10"
>
<Star
className={cn(
"w-[18px] h-[18px]",
starred ? "fill-amber-400 text-amber-400" : "text-muted-foreground/60"
)}
/>
</button>
<span className="flex h-10 items-center">
<button
type="button"
aria-label={starLabel}
aria-pressed={starred}
title={starLabel}
onClick={(e) => {
e.stopPropagation();
onToggleStar();
}}
className="p-1 rounded-full transition-colors hover:bg-foreground/10"
>
<Star
className={cn(
"w-[18px] h-[18px]",
starred ? "fill-amber-400 text-amber-400" : "text-muted-foreground/60"
)}
/>
</button>
</span>
)}
</div>
);
@@ -686,19 +733,21 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
)}
{!mvRow && density !== 'extra-compact' && (
<div className="relative flex flex-shrink-0 self-center">
<SelectableAvatar
name={sender?.name}
email={sender?.email}
size={oneLineRow ? "sm" : "md"}
className="shadow-sm"
disableImages={hideJunkAvatarImages}
checked={isChecked}
onToggle={handleCheckboxClick}
selectLabel={selectRowLabel}
/>
{isUnread && <UnreadDot besideAvatar />}
</div>
<AvatarGutter onToggle={handleCheckboxClick}>
<div className="relative flex flex-shrink-0">
<SelectableAvatar
name={sender?.name}
email={sender?.email}
size={oneLineRow ? "sm" : "md"}
className="shadow-sm"
disableImages={hideJunkAvatarImages}
checked={isChecked}
onToggle={handleCheckboxClick}
selectLabel={selectRowLabel}
/>
{isUnread && <UnreadDot besideAvatar />}
</div>
</AvatarGutter>
)}
{isUnread && !mvRow && density === 'extra-compact' && (
@@ -1385,46 +1434,48 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)}
{!mvRow && density !== 'extra-compact' && (
<div className="relative flex-shrink-0 self-center">
<SelectableAvatar
name={avatarPerson?.name}
email={avatarPerson?.email}
size={oneLineRow ? "sm" : "md"}
className="shadow-sm"
disableImages={hideJunkAvatarImages}
checked={isChecked}
onToggle={handleThreadCheckboxClick}
selectLabel={selectRowLabel}
/>
{hasUnread && <UnreadDot besideAvatar />}
{!isMobile && !columns && (
<button
data-expand-toggle
onClick={(e) => {
e.stopPropagation();
onToggleExpand();
}}
className={cn(
"absolute -bottom-2.5 left-1/2 -translate-x-1/2 p-0.5 rounded-full",
"transition-all duration-200",
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
"text-muted-foreground hover:text-foreground",
"bg-background border border-border"
)}
aria-expanded={isExpanded}
aria-label={isExpanded ? t('collapse_named', { subject: threadSubject }) : t('expand_named', { subject: threadSubject })}
>
{isLoading ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
</button>
)}
</div>
<AvatarGutter onToggle={handleThreadCheckboxClick}>
<div className="relative flex-shrink-0">
<SelectableAvatar
name={avatarPerson?.name}
email={avatarPerson?.email}
size={oneLineRow ? "sm" : "md"}
className="shadow-sm"
disableImages={hideJunkAvatarImages}
checked={isChecked}
onToggle={handleThreadCheckboxClick}
selectLabel={selectRowLabel}
/>
{hasUnread && <UnreadDot besideAvatar />}
{!isMobile && !columns && (
<button
data-expand-toggle
onClick={(e) => {
e.stopPropagation();
onToggleExpand();
}}
className={cn(
"absolute -bottom-2.5 left-1/2 -translate-x-1/2 p-0.5 rounded-full",
"transition-all duration-200",
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
"text-muted-foreground hover:text-foreground",
"bg-background border border-border"
)}
aria-expanded={isExpanded}
aria-label={isExpanded ? t('collapse_named', { subject: threadSubject }) : t('expand_named', { subject: threadSubject })}
>
{isLoading ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
</button>
)}
</div>
</AvatarGutter>
)}
{hasUnread && !mvRow && density === 'extra-compact' && (