From c87ef9c44a89a21201db239ae3d82e4d1142661b Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 14 Oct 2026 09:28:56 +0200
Subject: [PATCH] fix: move plugin buttons in a narrow selection toolbar into a
more menu so the toolbar no longer wraps
---
.../mail-list-plugin-surfaces.test.tsx | 35 ++++++++++++++
components/email/email-list.tsx | 36 ++++++++++----
components/plugins/plugin-actions.tsx | 47 +++++++++++++++++--
3 files changed, 107 insertions(+), 11 deletions(-)
diff --git a/components/email/__tests__/mail-list-plugin-surfaces.test.tsx b/components/email/__tests__/mail-list-plugin-surfaces.test.tsx
index e228eb913..cba16b2d7 100644
--- a/components/email/__tests__/mail-list-plugin-surfaces.test.tsx
+++ b/components/email/__tests__/mail-list-plugin-surfaces.test.tsx
@@ -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();
+
+ 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']) });
diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx
index 3c974a155..a52fe0b68 100644
--- a/components/email/email-list.tsx
+++ b/components/email/email-list.tsx
@@ -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"
)}
>
-
-
-
+
+
+
{t('batch_actions.selected_messages', { count: selectedEmailKeys.size })}
-
+
)}
-
+
diff --git a/components/plugins/plugin-actions.tsx b/components/plugins/plugin-actions.tsx
index 7f518bb0c..3c6359d5f 100644
--- a/components/plugins/plugin-actions.tsx
+++ b/components/plugins/plugin-actions.tsx
@@ -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
(null);
if (actions.length === 0) return null;
+ if (overflow && variant === 'buttons') {
+ return ;
+ }
const run = async (action: RegisteredAction) => {
const key = `${action.pluginId}:${action.id}`;
@@ -101,3 +108,37 @@ export function PluginActions({ surface, context, variant = 'buttons', mailboxRo
);
}
+
+/** The actions of a surface behind one "more" button, for toolbars without room for them. */
+function PluginActionsOverflow({ surface, context, mailboxRole, mimeType, className }: Pick) {
+ const t = useTranslations('email_viewer');
+ const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu();
+ return (
+
+
+
+
+
+
+ );
+}