fix: move plugin buttons in a narrow selection toolbar into a more menu so the toolbar no longer wraps

This commit is contained in:
Linus Rath
2026-10-14 09:28:56 +02:00
parent 937025be8d
commit c87ef9c44a
3 changed files with 107 additions and 11 deletions
@@ -155,6 +155,41 @@ describe('message-bulk-actions', () => {
expect(screen.queryByRole('button', { name: 'Make tasks' })).toBeNull();
});
it('puts them behind one "more" button when the list is narrow', async () => {
// The list next to an open folder sidebar, or on a phone.
class NarrowObserver {
constructor(private cb: ResizeObserverCallback) {}
observe(el: Element) {
this.cb([{ target: el, contentRect: { width: 384, height: 48 } } as unknown as ResizeObserverEntry], this as unknown as ResizeObserver);
}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', NarrowObserver);
try {
const emails = [mail('a'), mail('b')];
useEmailStore.setState({ emails, selectedEmailKeys: new Set(['a', 'b']) });
act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [
{ id: 'make', label: 'Make tasks' },
{ id: 'more', label: 'Remind me' },
]));
render(<EmailList emails={emails} />);
expect(screen.queryByRole('button', { name: 'Make tasks' })).toBeNull();
// "Clear selection" keeps its name for screen readers but drops the text.
const clear = screen.getByRole('button', { name: 'batch_actions.clear_selection' });
expect(clear).not.toHaveTextContent('batch_actions.clear_selection');
fireEvent.click(screen.getByRole('button', { name: 'more_actions' }));
const item = await screen.findByRole('menuitem', { name: 'Make tasks' });
expect(screen.getByRole('menuitem', { name: 'Remind me' })).toBeInTheDocument();
await act(async () => { fireEvent.click(item); });
expect(dispatch).toHaveBeenCalledWith('tasks', 'message-bulk-actions', 'make', { emailIds: ['a', 'b'] });
} finally {
vi.unstubAllGlobals();
}
});
it('adds them to the Mountain View toolbar too', async () => {
const emails = [mail('a'), mail('b')];
useEmailStore.setState({ emails, selectedEmailKeys: new Set(['b']) });
+28 -8
View File
@@ -9,7 +9,7 @@ import { listVerificationCode } from "@/lib/verification-code";
import { EmailContextMenu, type CopyTargetAccount } from "./email-context-menu";
import { BatchTagButton } from "./batch-tag-button";
import { cn, cleanPreview } from "@/lib/utils";
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck } from "@/components/icons";
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck, X } from "@/components/icons";
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
@@ -75,6 +75,9 @@ interface EmailListProps {
hideBatchToolbar?: boolean;
}
/** Below this width the batch toolbar drops its labels (see batchNarrow). */
const BATCH_TOOLBAR_WIDE = 480;
export function EmailList({
emails,
selectedEmailId,
@@ -267,6 +270,20 @@ export function EmailList({
observer.observe(toolbar);
return () => observer.disconnect();
}, []);
// A narrow list (folder sidebar open, phones) has no room for the batch
// toolbar's labels: plugin buttons go behind one "more" button and "Clear
// selection" becomes an icon, instead of wrapping into each other.
const [batchNarrow, setBatchNarrow] = useState(false);
useEffect(() => {
const toolbar = batchToolbarRef.current;
if (!toolbar || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver((entries) => {
const width = entries[0]?.contentRect.width ?? toolbar.getBoundingClientRect().width;
if (width > 0) setBatchNarrow(width < BATCH_TOOLBAR_WIDE);
});
observer.observe(toolbar);
return () => observer.disconnect();
}, []);
// One tag treatment for the whole list, measured from the scroll container.
const tagDisplay = useMeasuredTagDisplay(parentRef);
const density = useSettingsStore((state) => state.density);
@@ -594,13 +611,13 @@ export function EmailList({
hasSelection && !isScheduledView && !hideBatchToolbar ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
)}
>
<div className="px-4 py-2 border-b bg-accent/30 border-border flex items-center justify-between">
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-left-3 duration-300">
<span className="text-sm font-medium text-foreground">
<div className={cn("py-2 border-b bg-accent/30 border-border flex items-center justify-between gap-2", batchNarrow ? "px-2" : "px-4")} data-batch-narrow={batchNarrow ? "" : undefined}>
<div className="flex items-center gap-2 min-w-0 animate-in fade-in slide-in-from-left-3 duration-300">
<span className="text-sm font-medium text-foreground whitespace-nowrap truncate">
{t('batch_actions.selected_messages', { count: selectedEmailKeys.size })}
</span>
</div>
<div className="flex items-center gap-1 animate-in fade-in slide-in-from-right-3 duration-300">
<div className={cn("flex items-center flex-shrink-0 animate-in fade-in slide-in-from-right-3 duration-300", batchNarrow ? "gap-0.5 [&_button]:px-2" : "gap-1")}>
<Button
variant="ghost"
size="sm"
@@ -674,18 +691,21 @@ export function EmailList({
context={bulkActionContext}
mailboxRole={effectiveMailboxRole ?? null}
compact
overflow={batchNarrow}
className="flex-nowrap"
/>
)}
<div className="w-px h-6 bg-border mx-1" />
<div className={cn("w-px h-6 bg-border", batchNarrow ? "mx-0.5" : "mx-1")} />
<Button
variant="ghost"
size="sm"
onClick={clearSelection}
title={t('batch_actions.clear_selection')}
aria-label={t('batch_actions.clear_selection')}
disabled={isProcessing}
className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50 whitespace-nowrap"
>
{t('batch_actions.clear_selection')}
{batchNarrow ? <X className="w-4 h-4" /> : t('batch_actions.clear_selection')}
</Button>
</div>
</div>
+44 -3
View File
@@ -1,8 +1,10 @@
'use client';
import { useSyncExternalStore, useState } from 'react';
import { iconForName, Puzzle } from '@/components/icons';
import { ContextMenuItem } from '@/components/ui/context-menu';
import { useTranslations } from 'next-intl';
import { iconForName, MoreHorizontal, Puzzle } from '@/components/icons';
import { ContextMenu, ContextMenuItem } from '@/components/ui/context-menu';
import { useContextMenu } from '@/hooks/use-context-menu';
import { cn } from '@/lib/utils';
import {
actionApplies,
@@ -32,6 +34,8 @@ interface PluginActionsProps {
className?: string;
/** Icon-only buttons with the label as tooltip (narrow toolbars). */
compact?: boolean;
/** No room for buttons: one "more" button that opens the actions as a menu. */
overflow?: boolean;
/** Called after a click, e.g. to close the menu the entry sits in. */
onRan?: () => void;
}
@@ -40,10 +44,13 @@ interface PluginActionsProps {
* Buttons or menu entries plugins declared with api.ui.setActions. The host
* draws them so they match the app; the plugin gets the click.
*/
export function PluginActions({ surface, context, variant = 'buttons', mailboxRole, mimeType, className, compact, onRan }: PluginActionsProps) {
export function PluginActions({ surface, context, variant = 'buttons', mailboxRole, mimeType, className, compact, overflow, onRan }: PluginActionsProps) {
const actions = usePluginActions(surface, { mailboxRole, mimeType });
const [busy, setBusy] = useState<string | null>(null);
if (actions.length === 0) return null;
if (overflow && variant === 'buttons') {
return <PluginActionsOverflow surface={surface} context={context} mailboxRole={mailboxRole} mimeType={mimeType} className={className} />;
}
const run = async (action: RegisteredAction) => {
const key = `${action.pluginId}:${action.id}`;
@@ -101,3 +108,37 @@ export function PluginActions({ surface, context, variant = 'buttons', mailboxRo
</div>
);
}
/** The actions of a surface behind one "more" button, for toolbars without room for them. */
function PluginActionsOverflow({ surface, context, mailboxRole, mimeType, className }: Pick<PluginActionsProps, 'surface' | 'context' | 'mailboxRole' | 'mimeType' | 'className'>) {
const t = useTranslations('email_viewer');
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<true>();
return (
<div className={cn('inline-flex items-center', className)} data-plugin-actions={surface} data-plugin-actions-overflow="">
<button
type="button"
title={t('more_actions')}
aria-label={t('more_actions')}
aria-haspopup="menu"
aria-expanded={contextMenu.isOpen}
onClick={(e) => { e.stopPropagation(); openContextMenu(e, true); }}
className={cn(
'inline-flex h-8 items-center rounded-md px-2 text-muted-foreground transition-colors',
'hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
>
<MoreHorizontal className="h-4 w-4" />
</button>
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
<PluginActions
surface={surface}
variant="menu"
context={context}
mailboxRole={mailboxRole}
mimeType={mimeType}
onRan={closeContextMenu}
/>
</ContextMenu>
</div>
);
}