mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: tick a row from anywhere beside its avatar or checkbox #1381
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
Reference in New Issue
Block a user