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 ( +
+ + + + +
+ ); +}