fix: keep the sender name readable next to several tags or a folder chip

This commit is contained in:
Linus Rath
2026-11-03 05:34:59 +01:00
parent 091ef34a5e
commit 351e41d495
3 changed files with 63 additions and 11 deletions
@@ -2,6 +2,7 @@ import { Profiler } from 'react';
import { act, render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ThreadListItem } from '../thread-list-item';
import { TagDisplayContext, type TagDisplay } from '@/hooks/use-tag-display';
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
import { useEmailStore } from '@/stores/email-store';
import { useUIStore } from '@/stores/ui-store';
@@ -142,3 +143,45 @@ describe('ThreadListItem folder chip in search results (#1172)', () => {
});
});
});
describe('ThreadListItem sender line in a narrow list', () => {
const display: TagDisplay = { variant: 'badge', placement: 'sender', tight: true };
const tagged = (keywords: Record<string, boolean>) => [makeEmail({ keywords: { $seen: true, ...keywords } })];
const renderIn = (value: TagDisplay, emails: Email[]) => {
const [thread] = groupEmailsByThread(emails);
return render(
<TagDisplayContext.Provider value={value}>
<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={noop} onEmailSelect={noop} />
</TagDisplayContext.Provider>,
);
};
beforeEach(() => {
useUIStore.setState({ isMobile: true, isTablet: false, isDesktop: false });
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split', interfaceLayout: 'default', showColorNames: false });
useEmailStore.setState({ selectedEmailKeys: new Set<string>(), selectedMailbox: 'inbox', isUnifiedView: false, selectedKeyword: null, searchQuery: '', searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: 'archive' });
});
const dots = (c: HTMLElement) => c.querySelectorAll('[data-tag-dot]').length;
it('names a single tag', () => {
const { container } = renderIn(display, tagged({ '$label:red': true }));
expect(dots(container)).toBe(0);
});
it('turns several tags into dots so the name keeps its room', () => {
const { container } = renderIn(display, tagged({ '$label:red': true, '$label:blue': true }));
expect(dots(container)).toBe(2);
});
it('turns a tag into a dot next to the folder chip', () => {
useEmailStore.setState({ searchQuery: 'invoice', searchMailboxId: '' });
const { container } = renderIn(display, tagged({ '$label:red': true }));
expect(dots(container)).toBe(1);
});
it('keeps the names while the list has room', () => {
const { container } = renderIn({ ...display, tight: false }, tagged({ '$label:red': true, '$label:blue': true }));
expect(dots(container)).toBe(0);
});
});
+10 -10
View File
@@ -310,10 +310,10 @@ function describeAccount(label: string | undefined, account: AccountEntry | unde
* aggregate "All …" views (All Mail / unified / cross-account) where rows come
* from different folders. `email.sourceFolder` is stamped at fetch time.
*/
function SourceFolderTag({ name }: { name: string }) {
function SourceFolderTag({ name, tight }: { name: string; tight?: boolean }) {
return (
<span
className="inline-flex max-w-[8rem] shrink-0 items-center gap-1 truncate rounded-full border border-border bg-muted/40 px-1.5 py-0.5 text-[0.6875rem] text-muted-foreground"
className={cn("inline-flex shrink-0 items-center gap-1 truncate rounded-full border border-border bg-muted/40 px-1.5 py-0.5 text-[0.6875rem] text-muted-foreground", tight ? "max-w-[4.5rem]" : "max-w-[8rem]")}
title={name}
>
<Folder className="h-3 w-3 shrink-0" />
@@ -451,7 +451,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const formatAddress = useOwnDomainAddress();
const tagScope = useEmailTagScope(email);
const { sortTagIds, tagColor } = useKeywordFormat(tagScope);
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
const { variant: tagVariant, placement: tagPlacement, tight: senderLineTight } = useTagDisplay();
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
const tintListRowsByAccount = useSettingsStore((state) => state.tintListRowsByAccount);
// The tint replaces the dot, and the dot can be switched off on its own (#912).
@@ -845,7 +845,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</>
)}
{showPaperclip && <StatusIcon icon={Paperclip} label={tStatus('has_attachment')} className="w-3.5 h-3.5 text-muted-foreground" />}
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} tight={senderLineTight} />}
{scheduledSendLabel ? (
<span
className={cn("inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-xs font-medium tabular-nums text-sky-700 dark:text-sky-300", columns && "max-w-28")}
@@ -894,7 +894,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{tagPlacement === 'sender' && tagIds.length > 0 && (
<span className={TAG_GROUP_CLASS}>
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
<TagBadge key={id} tagId={id} variant={senderLineTight && (showSourceFolder || tagIds.length > 1) ? 'dot' : tagVariant} scope={tagScope} />
))}
</span>
)}
@@ -923,7 +923,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} tight={senderLineTight} />}
{scheduledSendLabel ? (
<span
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-[0.6875rem] font-medium tabular-nums text-sky-700 dark:text-sky-300"
@@ -1181,7 +1181,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const tagScope = useEmailTagScope(latestEmail);
const { sortTagIds, tagColor } = useKeywordFormat(tagScope);
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
const { variant: tagVariant, placement: tagPlacement, tight: senderLineTight } = useTagDisplay();
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
const tintListRowsByAccount = useSettingsStore((state) => state.tintListRowsByAccount);
// The tint replaces the dot, and the dot can be switched off on its own (#912).
@@ -1616,7 +1616,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</>
)}
{hasAttachment && <StatusIcon icon={Paperclip} label={tStatus('has_attachment')} className="w-3.5 h-3.5 text-muted-foreground" />}
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} tight={senderLineTight} />}
{scheduledSendLabel ? (
<span
className={cn("inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-xs font-medium tabular-nums text-sky-700 dark:text-sky-300", columns && "max-w-28")}
@@ -1674,7 +1674,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
title={t('messages_tooltip', { count: emailCount })}
/>
{tagPlacement === 'sender' && tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
<TagBadge key={id} tagId={id} variant={senderLineTight && (showSourceFolder || tagIds.length > 1) ? 'dot' : tagVariant} scope={tagScope} />
))}
</span>
<div className="flex items-center gap-1.5">
@@ -1702,7 +1702,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} tight={senderLineTight} />}
{scheduledSendLabel ? (
<span
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-[0.6875rem] font-medium tabular-nums text-sky-700 dark:text-sky-300"
+10 -1
View File
@@ -18,14 +18,22 @@ const TAG_BESIDE_SUBJECT_MIN_WIDTH = 560;
*/
const TAG_NAME_MIN_WIDTH = 320;
/**
* Below this, a sender line with several tags or a folder chip leaves the
* sender's name a letter or two, so the tags give way to their dots.
*/
const SENDER_LINE_TIGHT_WIDTH = 480;
export interface TagDisplay {
/** Whether a tag is named or shown as colour alone. */
variant: TagBadgeVariant;
/** Which line of a multi-line row the tags belong on. */
placement: "subject" | "sender";
/** The sender line can't also hold a crowd of named tags and chips. */
tight: boolean;
}
const NAMED_BESIDE_SUBJECT: TagDisplay = { variant: "badge", placement: "subject" };
const NAMED_BESIDE_SUBJECT: TagDisplay = { variant: "badge", placement: "subject", tight: false };
/**
* How message rows should draw their tags.
@@ -65,6 +73,7 @@ export function useMeasuredTagDisplay(ref: RefObject<HTMLElement | null>): TagDi
return {
variant: width >= TAG_NAME_MIN_WIDTH ? "badge" : "dot",
placement: width >= TAG_BESIDE_SUBJECT_MIN_WIDTH ? "subject" : "sender",
tight: width < SENDER_LINE_TIGHT_WIDTH,
};
}, [width]);
}