mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 00:13:09 +00:00
fix: move plugin buttons in a narrow selection toolbar into a more menu so the toolbar no longer wraps
This commit is contained in:
@@ -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']) });
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user