fix: point Mail's arrows, indents and panels the right way in right-to-left languages, and keep English subjects and sizes readable there

This commit is contained in:
Linus Rath
2026-10-28 05:16:29 +01:00
parent 43dd87b301
commit d17277fab2
9 changed files with 66 additions and 28 deletions
@@ -0,0 +1,38 @@
// @vitest-environment node
import fs from 'fs';
import path from 'path';
import { describe, expect, it } from 'vitest';
// R11: directional icons flip only through the global list in globals.css; a
// local rtl: flip on top of it turns them back the wrong way (the phone
// search's back arrow pointed into the content in Arabic). Offsets use
// logical properties so subfolders indent on the reading side.
const root = path.resolve(__dirname, '../../..');
const mailFiles = [
'components/mail/mail-app.tsx',
'components/email/email-viewer.tsx',
'components/email/email-composer.tsx',
'components/email/plugin-search-results.tsx',
'components/email/thread-list-item.tsx',
'components/layout/sidebar.tsx',
'components/templates/template-picker.tsx',
'components/templates/template-manager-modal.tsx',
];
const read = (file: string) => fs.readFileSync(path.join(root, file), 'utf-8');
describe('Mail in right-to-left languages', () => {
it.each(mailFiles)('%s adds no local flip to directional icons', (file) => {
expect(read(file)).not.toMatch(/rtl:(-scale-x-100|rotate-180)/);
});
it.each(mailFiles)('%s uses no physical left offsets', (file) => {
const code = read(file).replace(/\/\/.*$/gm, '');
expect(code).not.toMatch(/paddingLeft:|\bleft-[03]\b|\btext-left\b|-right-1\b/);
});
it('isolates subjects in the list, so an English subject reads right in Arabic', () => {
const source = read('components/email/thread-list-item.tsx');
expect(source.match(/<bdi>\{subjectSnippet/g)).toHaveLength(4);
});
});
+3 -3
View File
@@ -4385,11 +4385,11 @@ export function EmailComposer({
// Real byte progress from the transport; see
// ComposerAttachment.progress for who reports it.
<div
className="absolute bottom-0 left-0 h-0.5 bg-primary/60 transition-[width] duration-300 ease-out"
className="absolute bottom-0 start-0 h-0.5 bg-primary/60 transition-[width] duration-300 ease-out"
style={{ width: `${att.progress}%` }}
/>
) : (
<div className="absolute bottom-0 left-0 h-0.5 bg-primary/40 animate-[indeterminate_1.5s_ease-in-out_infinite]" style={{ width: '40%' }} />
<div className="absolute bottom-0 start-0 h-0.5 bg-primary/40 animate-[indeterminate_1.5s_ease-in-out_infinite]" style={{ width: '40%' }} />
)}
</div>
)}
@@ -5007,7 +5007,7 @@ const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
type="button"
disabled={isSearchingServer}
className={cn(
"w-full px-3 py-2 text-left text-sm flex items-center gap-2 text-muted-foreground hover:bg-muted disabled:opacity-60 disabled:cursor-default",
"w-full px-3 py-2 text-start text-sm flex items-center gap-2 text-muted-foreground hover:bg-muted disabled:opacity-60 disabled:cursor-default",
results.length > 0 && "border-t border-border"
)}
onMouseDown={(e) => {
+15 -15
View File
@@ -4509,11 +4509,11 @@ export function EmailViewer({
inert={!moreMenuOpen}
className={cn(
"bg-background border-s border-border z-[70]",
isPaneScoped ? "absolute inset-y-0 right-0 w-72" : "fixed inset-y-0 right-0 w-72 sm:hidden",
isPaneScoped ? "absolute inset-y-0 end-0 w-72" : "fixed inset-y-0 end-0 w-72 sm:hidden",
"transform",
moreMenuSlideEnabled && "transition-transform duration-300 ease-in-out",
"flex flex-col",
moreMenuOpen ? "translate-x-0" : "translate-x-full"
moreMenuOpen ? "translate-x-0" : "translate-x-full rtl:-translate-x-full"
)}>
{/* The fixed panel spans the full viewport height, so in the iOS PWA
its header would sit under the status bar without the safe-area
@@ -4748,7 +4748,7 @@ export function EmailViewer({
)}
<div className="flex-1 min-w-0">
<div className="flex items-start gap-2">
<h1 className={cn(
<h1 dir="auto" className={cn(
"text-foreground break-words min-w-0",
mvViewer ? "text-[22px] leading-7 font-normal" : "text-lg lg:text-xl font-bold tracking-tight"
)}>
@@ -4831,7 +4831,7 @@ export function EmailViewer({
</span>
{email.size > 0 && (
<div className="text-xs text-muted-foreground/60">
{formatFileSize(email.size)}
<bdi>{formatFileSize(email.size)}</bdi>
</div>
)}
</div>
@@ -5079,7 +5079,7 @@ export function EmailViewer({
{getAttachmentDisplayName(attachment.name, attachment.type)}
</span>
<span className="text-[10px] text-muted-foreground flex-shrink-0">
{formatFileSize(attachment.size)}
<bdi>{formatFileSize(attachment.size)}</bdi>
</span>
</div>
<div className={cn(
@@ -5145,7 +5145,7 @@ export function EmailViewer({
{getAttachmentDisplayName(attachment.name, attachment.type)}
</span>
<span className="text-[10px] text-muted-foreground ms-auto flex-shrink-0">
{formatFileSize(attachment.size)}
<bdi>{formatFileSize(attachment.size)}</bdi>
</span>
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5">
<button
@@ -5282,7 +5282,7 @@ export function EmailViewer({
</span>
{email.size > 0 && !mvMobileViewer && (
<div className="text-xs text-muted-foreground/60">
{formatFileSize(email.size)}
<bdi>{formatFileSize(email.size)}</bdi>
</div>
)}
</div>
@@ -5593,7 +5593,7 @@ export function EmailViewer({
<Row label={t('subject')}>{email.subject || <span className="italic text-muted-foreground">{t('details.no_subject')}</span>}</Row>
)}
<Row label={t('details.size')}>
{formatFileSize(email.size)}
<bdi>{formatFileSize(email.size)}</bdi>
{topMimeType && (
<span className="text-muted-foreground"> · <span className="font-mono text-xs">{topMimeType}</span></span>
)}
@@ -5823,7 +5823,7 @@ export function EmailViewer({
<div
ref={belowHeaderGhostRef}
aria-hidden="true"
className="absolute inset-y-0 left-0 right-0 flex items-center gap-2 invisible pointer-events-none whitespace-nowrap"
className="absolute inset-0 flex items-center gap-2 invisible pointer-events-none whitespace-nowrap"
>
{effectiveAttachments.map((attachment) => {
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
@@ -5849,7 +5849,7 @@ export function EmailViewer({
{getAttachmentDisplayName(attachment.name, attachment.type)}
</span>
<span className="text-xs">
{formatFileSize(attachment.size)}
<bdi>{formatFileSize(attachment.size)}</bdi>
</span>
</div>
);
@@ -5903,7 +5903,7 @@ export function EmailViewer({
{getAttachmentDisplayName(attachment.name, attachment.type)}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0">
{formatFileSize(attachment.size)}
<bdi>{formatFileSize(attachment.size)}</bdi>
</span>
</div>
<div className={cn(
@@ -5969,7 +5969,7 @@ export function EmailViewer({
{getAttachmentDisplayName(attachment.name, attachment.type)}
</span>
<span className="text-xs text-muted-foreground ms-auto flex-shrink-0">
{formatFileSize(attachment.size)}
<bdi>{formatFileSize(attachment.size)}</bdi>
</span>
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5">
<button
@@ -6048,7 +6048,7 @@ export function EmailViewer({
{getAttachmentDisplayName(attachment.name, attachment.type)}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0">
{formatFileSize(attachment.size)}
<bdi>{formatFileSize(attachment.size)}</bdi>
</span>
{/* Phones have no hover: the plugin menu stays visible. */}
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} className="-me-1 flex-shrink-0" iconClassName="w-3.5 h-3.5" />
@@ -6114,7 +6114,7 @@ export function EmailViewer({
{getAttachmentDisplayName(attachment.name, attachment.type)}
</span>
<span className="text-[10px] text-muted-foreground ms-auto flex-shrink-0">
{formatFileSize(attachment.size)}
<bdi>{formatFileSize(attachment.size)}</bdi>
</span>
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} className="flex-shrink-0" iconClassName="w-3.5 h-3.5" />
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5">
@@ -6374,7 +6374,7 @@ export function EmailViewer({
{isMobile && !mvMobileViewer && (
<nav ref={bottomBarRef} className={cn(
"z-50 bg-background border-t border-border overflow-hidden pb-[calc(env(safe-area-inset-bottom)/2)]",
isPaneScoped ? "absolute bottom-0 left-0 right-0" : "fixed bottom-0 left-0 right-0 sm:hidden",
isPaneScoped ? "absolute bottom-0 inset-x-0" : "fixed bottom-0 inset-x-0 sm:hidden",
)}>
<div className="flex items-center overflow-x-auto mobile-scroll-hidden">
<button
+1 -1
View File
@@ -132,7 +132,7 @@ export function PluginSearchResults({ surface, onOpenEmail }: PluginSearchResult
aria-controls={listId}
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground hover:text-foreground"
>
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5 rtl:-scale-x-100" />}
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
<span className="truncate">{group.heading}</span>
<span className="font-normal">({group.results.length})</span>
</button>
+4 -4
View File
@@ -707,7 +707,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
'min-w-0 truncate',
isUnread ? (mvRow ? 'font-bold text-foreground' : 'font-semibold text-foreground') : 'text-foreground/90'
)}>
{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (email.subject || '(no subject)')}
<bdi>{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (email.subject || '(no subject)')}</bdi>
</span>
<PluginRowBadges email={email} className={mvRow ? "ms-2" : undefined} />
{(previewSnippet || inlinePreview) && (
@@ -852,7 +852,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (email.subject || "(no subject)")}
<bdi>{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (email.subject || "(no subject)")}</bdi>
</span>
<PluginRowBadges email={email} />
</div>
@@ -1362,7 +1362,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
'min-w-0 truncate',
hasUnread ? (mvRow ? 'font-bold text-foreground' : 'font-semibold text-foreground') : 'text-foreground/90'
)}>
{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (latestEmail.subject || '(no subject)')}
<bdi>{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (latestEmail.subject || '(no subject)')}</bdi>
</span>
<PluginRowBadges email={latestEmail} className={mvRow ? "ms-2" : undefined} />
{(previewSnippet || inlinePreview) && (
@@ -1514,7 +1514,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (latestEmail.subject || "(no subject)")}
<bdi>{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (latestEmail.subject || "(no subject)")}</bdi>
</span>
<PluginRowBadges email={latestEmail} />
</div>
+1 -1
View File
@@ -413,7 +413,7 @@ function SidebarRow({
{!isCollapsed && (
<div
className="flex items-center flex-shrink-0"
style={{ paddingLeft: leftPad }}
style={{ paddingInlineStart: leftPad }}
>
{hasChildren && onExpandToggle ? (
<button
+2 -2
View File
@@ -4880,7 +4880,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onClick={() => setMobileSearchOpen(false)}
aria-label={t('sidebar.mobile.go_back')}
>
<ArrowLeft className="h-5 w-5 rtl:-scale-x-100" />
<ArrowLeft className="h-5 w-5" />
</Button>
<span className="font-medium truncate">{t('sidebar.search_placeholder')}</span>
</div>
@@ -4963,7 +4963,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
>
<Filter className="w-4 h-4" />
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
<span className="absolute -top-1 -right-1 flex items-center justify-center w-4 h-4 text-[10px] font-bold rounded-full bg-primary text-primary-foreground">
<span className="absolute -top-1 -end-1 flex items-center justify-center w-4 h-4 text-[10px] font-bold rounded-full bg-primary text-primary-foreground">
{activeFilterCount(searchFilters)}
</span>
)}
@@ -99,7 +99,7 @@ export function TemplateManagerModal({ isOpen, onClose }: TemplateManagerModalPr
{!isCreating && !editingId && (
<div className="flex items-center gap-3 mb-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
+1 -1
View File
@@ -150,7 +150,7 @@ export function TemplatePicker({ isOpen, onClose, onSelect }: TemplatePickerProp
<div className="px-4 py-2 border-b border-border">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}