mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: list plugin composer buttons under Options when the composer is too narrow for them
This commit is contained in:
@@ -366,8 +366,9 @@ describe('composer-options-menu and composer-toolbar-actions', () => {
|
||||
const button = screen.getByRole('button', { name: 'Assistant' });
|
||||
expect(button).toHaveAttribute('title', 'Assistant (Alt+A)');
|
||||
expect(button.className).toContain('h-9');
|
||||
// Icon only unless the bar is wide enough for the label next to the app's buttons.
|
||||
expect(button.querySelector('span')!.className).toContain('@[44rem]/composer-bar:inline');
|
||||
// In the bar only when it is wide enough next to the app's own buttons...
|
||||
expect(button.className).toMatch(/(^|\s)hidden(\s|$)/);
|
||||
expect(button.className).toContain('@[44rem]/composer-bar:inline-flex');
|
||||
let bar: HTMLElement | null = button;
|
||||
while (bar && !bar.classList.contains('@container/composer-bar')) bar = bar.parentElement;
|
||||
expect(bar).not.toBeNull();
|
||||
@@ -375,6 +376,14 @@ describe('composer-options-menu and composer-toolbar-actions', () => {
|
||||
expect(button.closest('[data-composer-plugin-row]')).toBeNull();
|
||||
await act(async () => { fireEvent.click(button); });
|
||||
expect(dispatch).toHaveBeenCalledWith('ai', 'composer-toolbar-actions', 'ask', { composerId: expect.any(String) });
|
||||
|
||||
// ...and otherwise in the Options menu, which is shown only while narrow.
|
||||
dispatch.mockClear();
|
||||
fireEvent.click(screen.getByTestId('composer-options-menu'));
|
||||
const entry = screen.getByRole('menuitem', { name: 'Assistant' });
|
||||
expect(entry.closest('[data-narrow-only]')!.className).toContain('@[44rem]/composer-bar:hidden');
|
||||
await act(async () => { fireEvent.click(entry); });
|
||||
expect(dispatch).toHaveBeenCalledWith('ai', 'composer-toolbar-actions', 'ask', { composerId: expect.any(String) });
|
||||
});
|
||||
|
||||
it('refuses odd values for checked and description', () => {
|
||||
|
||||
@@ -150,12 +150,19 @@ interface PluginItemsProps {
|
||||
onSelect?: () => void;
|
||||
/** Draw a separator before the entries (when the menu has its own above). */
|
||||
separator?: boolean;
|
||||
/** Only while the composer bar is narrower than 44rem (see ComposerPluginToolbarButtons). */
|
||||
onlyWhenNarrow?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buttons plugins declared for the composer's bottom bar
|
||||
* (composer-toolbar-actions), drawn like Attach and Templates. An action's
|
||||
* shortcut is shown in its tooltip; the composer binds it.
|
||||
*
|
||||
* The bar is a container (`@container/composer-bar`). Below 44rem there is
|
||||
* no room next to the app's own buttons, Discard and Send, so the buttons
|
||||
* are hidden and the same actions are listed in the Options menu instead
|
||||
* (ComposerPluginMenuItems with `onlyWhenNarrow`).
|
||||
*/
|
||||
export function ComposerPluginToolbarButtons({ surface, context }: { surface: "composer-toolbar-actions"; context: () => Record<string, unknown> }) {
|
||||
const actions = usePluginActions(surface);
|
||||
@@ -170,7 +177,7 @@ export function ComposerPluginToolbarButtons({ surface, context }: { surface: "c
|
||||
key={`${action.pluginId}:${action.id}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={cn("h-9 gap-1.5 px-2 sm:px-2.5", action.danger && "text-destructive")}
|
||||
className={cn("hidden @[44rem]/composer-bar:inline-flex h-9 gap-1.5 px-2 sm:px-2.5", action.danger && "text-destructive")}
|
||||
title={tip}
|
||||
aria-label={action.label}
|
||||
aria-keyshortcuts={action.shortcut}
|
||||
@@ -178,9 +185,7 @@ export function ComposerPluginToolbarButtons({ surface, context }: { surface: "c
|
||||
onClick={() => { void runAction(action, surface, context()); }}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{/* The label only when the bar has room for it next to the app's own
|
||||
buttons; otherwise the icon, with the label as tooltip. */}
|
||||
<span className="hidden @[44rem]/composer-bar:inline text-sm">{action.label}</span>
|
||||
<span className="text-sm">{action.label}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
@@ -188,12 +193,18 @@ export function ComposerPluginToolbarButtons({ surface, context }: { surface: "c
|
||||
);
|
||||
}
|
||||
|
||||
/** Shows its children only while the composer bar is narrower than 44rem. */
|
||||
function NarrowOnly({ active, children }: { active: boolean; children: React.ReactNode }) {
|
||||
if (!active) return <>{children}</>;
|
||||
return <div className="@[44rem]/composer-bar:hidden" data-narrow-only="">{children}</div>;
|
||||
}
|
||||
|
||||
/** Entries plugins declared for a composer menu, drawn like the menu's own. */
|
||||
export function ComposerPluginMenuItems({ surface, context, onSelect, separator }: PluginItemsProps) {
|
||||
export function ComposerPluginMenuItems({ surface, context, onSelect, separator, onlyWhenNarrow }: PluginItemsProps) {
|
||||
const actions = usePluginActions(surface);
|
||||
if (actions.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
<NarrowOnly active={!!onlyWhenNarrow}>
|
||||
{separator && <div role="separator" className="my-1 h-px bg-border" />}
|
||||
{actions.map((action) => {
|
||||
const Icon = iconForName(action.icon) ?? Puzzle;
|
||||
@@ -234,6 +245,6 @@ export function ComposerPluginMenuItems({ surface, context, onSelect, separator
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</NarrowOnly>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4118,6 +4118,7 @@ export function EmailComposer({
|
||||
</ComposerToolbarMenuCheckItem>
|
||||
)}
|
||||
<ComposerPluginMenuItems surface="composer-options-menu" context={composerActionContext} onSelect={close} separator />
|
||||
<ComposerPluginMenuItems surface="composer-toolbar-actions" context={composerActionContext} onSelect={close} separator onlyWhenNarrow />
|
||||
</>}
|
||||
</ComposerToolbarMenu>
|
||||
<ComposerPluginToolbarButtons surface="composer-toolbar-actions" context={composerActionContext} />
|
||||
|
||||
@@ -19,7 +19,7 @@ import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Loader2, MoreHorizontal, Power, Puzzle, X } from '@/components/icons';
|
||||
import { iconForName, Loader2, MoreHorizontal, Power, Puzzle, X } from '@/components/icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PluginIframeSlot } from './plugin-iframe-slot';
|
||||
import type { SlotName } from '@/lib/plugin-types';
|
||||
@@ -79,7 +79,13 @@ export function readDialogButtons(value: unknown): DialogButtonSpec[] {
|
||||
const label = b.label.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim().slice(0, 40);
|
||||
if (!label) continue;
|
||||
const variant = b.variant === 'secondary' || b.variant === 'ghost' || b.variant === 'destructive' ? b.variant : 'primary';
|
||||
out.push({ id: b.id, label, variant, ...(b.disabled === true ? { disabled: true } : {}), ...(b.busy === true ? { busy: true } : {}) });
|
||||
const icon = typeof b.icon === 'string' && /^[A-Za-z0-9:-]{1,48}$/.test(b.icon) ? b.icon : undefined;
|
||||
out.push({
|
||||
id: b.id, label, variant,
|
||||
...(b.disabled === true ? { disabled: true } : {}),
|
||||
...(b.busy === true ? { busy: true } : {}),
|
||||
...(icon ? { icon } : {}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -235,7 +241,7 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
|
||||
className="gap-1.5"
|
||||
onClick={() => pressButton(b.id)}
|
||||
>
|
||||
{b.busy && <Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" />}
|
||||
{b.busy ? <Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" /> : <FooterIcon name={b.icon} />}
|
||||
{b.label}
|
||||
</Button>
|
||||
))}
|
||||
@@ -303,6 +309,12 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
|
||||
);
|
||||
}
|
||||
|
||||
/** A footer button's icon by name; unknown names show none. */
|
||||
function FooterIcon({ name }: { name?: string }) {
|
||||
const Glyph = name ? iconForName(name) : null;
|
||||
return Glyph ? <Glyph className="w-4 h-4" aria-hidden="true" /> : null;
|
||||
}
|
||||
|
||||
/** The "⋯" menu in a plugin dialog's header: the way out of a plugin that keeps asking. */
|
||||
function PluginDialogMenu({ name, onTurnOff }: { name: string; onTurnOff: () => void }) {
|
||||
const t = useTranslations('plugins.dialog');
|
||||
|
||||
@@ -191,7 +191,7 @@ describe('plugin dialog footer', () => {
|
||||
|
||||
act(() => (slotProps.setDialogButtons as (b: unknown) => void)([
|
||||
{ id: 'cancel', label: 'Cancel', variant: 'secondary' },
|
||||
{ id: 'send', label: 'Send', disabled: true },
|
||||
{ id: 'send', label: 'Send', disabled: true, icon: 'send' },
|
||||
{ id: 'bad id!', label: 'Dropped' },
|
||||
{ id: 'x', label: '' },
|
||||
]));
|
||||
@@ -200,6 +200,8 @@ describe('plugin dialog footer', () => {
|
||||
expect(buttons.map((b) => b.textContent)).toEqual(['Cancel', 'Send']);
|
||||
expect(buttons[0].className).toContain('border-input');
|
||||
expect(buttons[1]).toBeDisabled();
|
||||
expect(buttons[1].querySelector('svg')).not.toBeNull();
|
||||
expect(buttons[0].querySelector('svg')).toBeNull();
|
||||
|
||||
fireEvent.click(buttons[0]);
|
||||
expect(slotProps.dialogButtonClick).toEqual({ id: 'cancel', n: 1 });
|
||||
|
||||
Reference in New Issue
Block a user