fix: keep the selection toolbar inside the list column on a tablet

This commit is contained in:
Linus Rath
2026-10-31 20:23:05 +01:00
parent d20532af91
commit cb0f50198e
3 changed files with 194 additions and 77 deletions
@@ -0,0 +1,49 @@
// Tablet portrait: the selection toolbar ran past the list column and put
// Archive and Delete over the reading pane.
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fitToolbarActions } from '../selection-toolbar';
const ALL = ['star', 'read', 'tag', 'move', 'spam', 'archive', 'delete'] as const;
const opts = (width: number | null) => ({
width,
// EmailListScreen: 8 + 8 padding, 44 close, 24 + 4 + 4 count.
reserved: 92,
button: 44,
overflowOrder: ['spam', 'tag', 'star', 'read', 'move', 'archive', 'delete'] as const,
});
describe('fitToolbarActions', () => {
it('shows every action where they fit (a 412 dp phone)', () => {
expect(fitToolbarActions(ALL, opts(412))).toEqual({ visible: [...ALL], overflow: [] });
});
it('moves the least used into "More" on a narrow list column, keeping Archive and Delete', () => {
const fit = fitToolbarActions(ALL, opts(360));
expect(fit.overflow).toEqual(['tag', 'spam']);
expect(fit.visible).toEqual(['star', 'read', 'move', 'archive', 'delete']);
// The bar, its "More" button included, fits the column.
expect(92 + (fit.visible.length + 1) * 44).toBeLessThanOrEqual(360);
});
it('never needs more room than the column has', () => {
for (let width = 150; width <= 500; width += 7) {
const fit = fitToolbarActions(ALL, opts(width));
const buttons = fit.visible.length + (fit.overflow.length > 0 ? 1 : 0);
if (width >= 92 + 44) expect(92 + buttons * 44).toBeLessThanOrEqual(width);
expect([...fit.visible, ...fit.overflow].sort()).toEqual([...ALL].sort());
}
});
it('shows them all until the width is measured', () => {
expect(fitToolbarActions(ALL, opts(null)).overflow).toEqual([]);
});
it('is what the list header renders', () => {
const src = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailListScreen.tsx'), 'utf8');
expect(src).toMatch(/onLayout=\{\(e\) => setSelectionBarWidth\(e\.nativeEvent\.layout\.width\)\}/);
expect(src).toMatch(/toolbarFit\.visible\.map\(/);
expect(src).toMatch(/items=\{toolbarFit\.overflow\.map\(/);
});
});
+32
View File
@@ -0,0 +1,32 @@
// The list's selection toolbar on a narrow list column. Up to seven 44 dp
// actions sit beside the close button and the count; in a tablet's portrait
// split view the list column is narrower than that, and Archive and Delete
// were drawn over the reading pane. The actions that don't fit go into a
// "More" menu, the least used first, so Archive and Delete stay on the bar.
/** Which actions to show on the bar and which to put in the "More" menu. */
export function fitToolbarActions<K extends string>(
keys: readonly K[],
opts: {
/** The toolbar's measured width; null shows them all until it is known. */
width: number | null;
/** Room taken by everything that isn't an action (padding, close, count). */
reserved: number;
/** Width of one action button (and of the "More" button). */
button: number;
/** The actions to move into the menu, first to go first. */
overflowOrder: readonly K[];
},
): { visible: K[]; overflow: K[] } {
if (opts.width === null) return { visible: [...keys], overflow: [] };
const capacity = Math.max(0, Math.floor((opts.width - opts.reserved) / opts.button));
if (keys.length <= capacity) return { visible: [...keys], overflow: [] };
// One slot goes to the "More" button.
const room = Math.max(0, capacity - 1);
const order = [...opts.overflowOrder.filter((k) => keys.includes(k)), ...keys.filter((k) => !opts.overflowOrder.includes(k))];
const moved = new Set(order.slice(0, keys.length - room));
return {
visible: keys.filter((k) => !moved.has(k)),
overflow: keys.filter((k) => moved.has(k)),
};
}
+113 -77
View File
@@ -7,7 +7,7 @@ import {
Search, SquarePen, Menu, Filter, Square, SquareCheck, Minus, X,
Star, Paperclip, Mail as MailIcon, MailOpen, Trash2, RotateCcw, CalendarDays,
Archive, FolderInput, Tag, Import, ArrowDownWideNarrow, ArrowUpNarrowWide,
Pin, Reply, Forward, ShieldAlert, ShieldCheck, Folder, ArrowUpDown, Check, Globe,
Pin, Reply, Forward, ShieldAlert, ShieldCheck, Folder, ArrowUpDown, Check, Globe, MoreVertical,
} from '../components/icons';
import { GlobalSearchSheet } from '../components/search/GlobalSearchSheet';
import { ActionSheet } from '../components/email/ActionSheet';
@@ -90,6 +90,7 @@ import type { Attachment, Email } from '../api/types';
import { createFrameMonitor, logModuleTimes, perfOnce, perfStart } from '../lib/perf-marks';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { searchEverythingPlacement } from '../lib/search-everything-entry';
import { fitToolbarActions } from '../lib/selection-toolbar';
// The server refuses keywords per message (a read-only shared folder;
// Stalwart 1.0: over 128 bytes, or more than 100 on one message), and the
@@ -740,6 +741,9 @@ export default function EmailListScreen({
// Batch (multi-select) sheets.
const [batchMoveOpen, setBatchMoveOpen] = React.useState(false);
const [tagSheetOpen, setTagSheetOpen] = React.useState(false);
// The selection toolbar's width and its "More" menu (see fitToolbarActions).
const [selectionBarWidth, setSelectionBarWidth] = React.useState<number | null>(null);
const [selectionMoreOpen, setSelectionMoreOpen] = React.useState(false);
// The sheet's changes share one Undo, shown once it closes.
const tagUndoGroup = useTagUndoGroup(tagSheetOpen);
@@ -1341,6 +1345,69 @@ export default function EmailListScreen({
}
}, [mailboxes, currentMailboxId, selectMailbox]);
// The selection toolbar's actions, in bar order. What doesn't fit the
// list column goes into a "More" menu, the least used first.
type SelectionAction = 'star' | 'read' | 'tag' | 'move' | 'spam' | 'archive' | 'delete';
const selectionActions: Array<{ key: SelectionAction; label: string; icon: React.ReactNode; onPress: () => void }> = [
{
key: 'star',
label: allSelectedAreStarred ? t('context_menu.unstar', 'Unstar') : t('context_menu.star', 'Star'),
icon: (
<Star
size={20}
color={allSelectedAreStarred ? c.starred : c.text}
fill={allSelectedAreStarred ? c.starred : 'transparent'}
/>
),
onPress: () => reportFailure(handleBulkStar()),
},
{
key: 'read',
label: allSelectedAreRead
? t('email_list.batch_actions.mark_unread', 'Mark as unread')
: t('email_list.batch_actions.mark_read', 'Mark as read'),
icon: allSelectedAreRead ? <MailIcon size={20} color={c.text} /> : <MailOpen size={20} color={c.text} />,
onPress: () => reportFailure(handleBulkMarkReadToggle()),
},
{
key: 'tag',
label: t('context_menu.tag', 'Tag'),
icon: <Tag size={20} color={c.text} />,
onPress: () => setTagSheetOpen(true),
},
{
key: 'move',
label: t('email_viewer.move', 'Move'),
icon: <FolderInput size={20} color={c.text} />,
onPress: () => setBatchMoveOpen(true),
},
...(canSpamSelection ? [{
key: 'spam' as const,
label: inJunk ? t('context_menu.not_spam', 'Not spam') : t('context_menu.mark_as_spam', 'Report spam'),
icon: inJunk ? <ShieldCheck size={20} color={c.text} /> : <ShieldAlert size={20} color={c.text} />,
onPress: () => reportFailure(handleBulkSpam()),
}] : []),
...(canArchiveSelection ? [{
key: 'archive' as const,
label: t('context_menu.archive', 'Archive'),
icon: <Archive size={20} color={c.text} />,
onPress: () => reportFailure(handleBulkArchive()),
}] : []),
{
key: 'delete',
label: t('email_list.batch_actions.delete', 'Delete'),
icon: <Trash2 size={20} color={c.text} />,
onPress: () => reportFailure(handleBulkDelete()),
},
];
const toolbarFit = fitToolbarActions(selectionActions.map((a) => a.key), {
width: selectionBarWidth,
// Padding (sm each side), the close button and the count (min 24 + xs each side).
reserved: spacing.sm * 2 + componentSizes.buttonLg + 24 + spacing.xs * 2,
button: componentSizes.buttonLg,
overflowOrder: ['spam', 'tag', 'star', 'read', 'move', 'archive', 'delete'],
});
const renderSearchBar = (inHeader: boolean) => (
<View style={[styles.searchBar, inHeader && styles.searchBarInHeader]}>
<Pressable
@@ -1431,7 +1498,11 @@ export default function EmailListScreen({
{selectionMode ? (
// Keyed apart from the normal header: reused in place, its title
// kept the "1 email selected" label for screen readers.
<View key="selection-header" style={[styles.header, styles.selectionHeader]}>
<View
key="selection-header"
style={[styles.header, styles.selectionHeader]}
onLayout={(e) => setSelectionBarWidth(e.nativeEvent.layout.width)}
>
<Pressable
onPress={clearSelection}
style={styles.headerButton}
@@ -1449,87 +1520,32 @@ export default function EmailListScreen({
>
{selectionCountOnly ? String(selectedIds.size) : selectedLabel}
</Text>
<Pressable
onPress={() => reportFailure(handleBulkStar())}
style={styles.headerButton}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel={allSelectedAreStarred ? t('context_menu.unstar', 'Unstar') : t('context_menu.star', 'Star')}
>
<Star
size={20}
color={allSelectedAreStarred ? c.starred : c.text}
fill={allSelectedAreStarred ? c.starred : 'transparent'}
/>
</Pressable>
<Pressable
onPress={() => reportFailure(handleBulkMarkReadToggle())}
style={styles.headerButton}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel={allSelectedAreRead
? t('email_list.batch_actions.mark_unread', 'Mark as unread')
: t('email_list.batch_actions.mark_read', 'Mark as read')}
>
{allSelectedAreRead ? (
<MailIcon size={20} color={c.text} />
) : (
<MailOpen size={20} color={c.text} />
)}
</Pressable>
<Pressable
onPress={() => setTagSheetOpen(true)}
style={styles.headerButton}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel={t('context_menu.tag', 'Tag')}
>
<Tag size={20} color={c.text} />
</Pressable>
<Pressable
onPress={() => setBatchMoveOpen(true)}
style={styles.headerButton}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel={t('email_viewer.move', 'Move')}
>
<FolderInput size={20} color={c.text} />
</Pressable>
{canSpamSelection && (
{toolbarFit.visible.map((key) => {
const action = selectionActions.find((a) => a.key === key)!;
return (
<Pressable
key={key}
onPress={action.onPress}
style={styles.headerButton}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel={action.label}
>
{action.icon}
</Pressable>
);
})}
{toolbarFit.overflow.length > 0 ? (
<Pressable
onPress={() => reportFailure(handleBulkSpam())}
onPress={() => setSelectionMoreOpen(true)}
style={styles.headerButton}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel={inJunk ? t('context_menu.not_spam', 'Not spam') : t('context_menu.mark_as_spam', 'Report spam')}
accessibilityLabel={t('email_list.batch_actions.more', 'More actions')}
>
{inJunk ? (
<ShieldCheck size={20} color={c.text} />
) : (
<ShieldAlert size={20} color={c.text} />
)}
<MoreVertical size={20} color={c.text} />
</Pressable>
)}
{canArchiveSelection && (
<Pressable
onPress={() => reportFailure(handleBulkArchive())}
style={styles.headerButton}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel={t('context_menu.archive', 'Archive')}
>
<Archive size={20} color={c.text} />
</Pressable>
)}
<Pressable
onPress={() => reportFailure(handleBulkDelete())}
style={styles.headerButton}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel={t('email_list.batch_actions.delete', 'Delete')}
>
<Trash2 size={20} color={c.text} />
</Pressable>
) : null}
</View>
) : (
<View key="header" style={styles.header}>
@@ -2234,6 +2250,26 @@ export default function EmailListScreen({
}}
/>
<ActionSheet
visible={selectionMoreOpen && selectionMode && toolbarFit.overflow.length > 0}
title={selectedLabel}
onClose={() => setSelectionMoreOpen(false)}
items={toolbarFit.overflow.map((key) => {
const action = selectionActions.find((a) => a.key === key)!;
return {
key,
label: action.label,
icon: action.icon,
destructive: key === 'delete',
onPress: () => {
setSelectionMoreOpen(false);
// A sheet opened from here waits for this one to go (iOS
// drops a Modal shown while another is dismissing).
setTimeout(action.onPress, Platform.OS === 'ios' ? 350 : 0);
},
};
})}
/>
<ActionSheet
visible={sortMenuOpen}
title={t('email_list.sort.title', 'Sort')}