Files
webmail/components/email/composer-toolbar-menu.tsx
T

284 lines
10 KiB
TypeScript

"use client";
import { useEffect, useId, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Check, ChevronDown, iconForName, Puzzle, type AppIcon } from "@/components/icons";
import { usePluginActions } from "@/components/plugins/plugin-actions";
import { runAction } from "@/lib/plugin-sandbox/actions";
import { cn } from "@/lib/utils";
interface ComposerToolbarMenuProps {
icon: AppIcon;
label: string;
/** Number of options that are switched on, shown as a badge on the button. */
activeCount?: number;
testId?: string;
children: (close: () => void) => React.ReactNode;
}
/**
* A labelled toolbar button that opens a small menu above it. The label is
* always the accessible name; it is only hidden visually on narrow screens.
*/
export function ComposerToolbarMenu({ icon: Icon, label, activeCount = 0, testId, children }: ComposerToolbarMenuProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const menuId = useId();
useEffect(() => {
if (!open) return;
const onPointerDown = (event: MouseEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.stopPropagation();
setOpen(false);
}
};
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKeyDown, true);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKeyDown, true);
};
}, [open]);
const close = () => setOpen(false);
return (
<div ref={rootRef} className="relative">
<Button
type="button"
variant="ghost"
onClick={() => setOpen((v) => !v)}
// 44px on a phone, where 36px was hard to hit with a finger.
className={cn("h-9 max-md:h-11 max-md:min-w-11 justify-center gap-1.5 px-2 sm:px-2.5", open && "bg-muted")}
title={label}
aria-label={label}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
data-testid={testId}
>
<Icon className="w-4 h-4" />
<span className="hidden sm:inline text-sm">{label}</span>
{activeCount > 0 && (
<span className="min-w-4 h-4 rounded-full bg-primary px-1 text-[10px] font-semibold leading-4 text-primary-foreground">
{activeCount}
</span>
)}
<ChevronDown className="hidden sm:block w-3.5 h-3.5 text-muted-foreground" />
</Button>
{open && (
<div
id={menuId}
role="menu"
aria-label={label}
className="absolute start-0 bottom-full z-50 mb-2 min-w-56 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg"
>
{children(close)}
</div>
)}
</div>
);
}
interface ItemProps {
icon?: AppIcon;
onSelect: () => void;
testId?: string;
children: React.ReactNode;
}
export function ComposerToolbarMenuItem({ icon: Icon, onSelect, testId, children }: ItemProps) {
return (
<button
type="button"
role="menuitem"
onClick={onSelect}
data-testid={testId}
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-start text-sm hover:bg-accent hover:text-accent-foreground"
>
{Icon ? <Icon className="w-4 h-4 shrink-0 text-muted-foreground" /> : null}
<span className="flex-1">{children}</span>
</button>
);
}
interface CheckItemProps {
checked: boolean;
onToggle: () => void;
description?: string;
testId?: string;
children: React.ReactNode;
}
/** A menu entry that stays open on click so several options can be set in one go. */
export function ComposerToolbarMenuCheckItem({ checked, onToggle, description, testId, children }: CheckItemProps) {
return (
<button
type="button"
role="menuitemcheckbox"
aria-checked={checked}
onClick={onToggle}
data-testid={testId}
className="flex w-full items-start gap-2 rounded-sm px-3 py-2 text-start text-sm hover:bg-accent hover:text-accent-foreground"
>
<span
className={cn(
"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border",
checked ? "border-primary bg-primary text-primary-foreground" : "border-muted-foreground/50"
)}
aria-hidden="true"
>
{checked && <Check className="w-3 h-3" />}
</span>
<span className="flex-1">
<span className="block">{children}</span>
{description && <span className="block text-xs text-muted-foreground">{description}</span>}
</span>
</button>
);
}
interface RadioItemProps {
checked: boolean;
onSelect: () => void;
testId?: string;
children: React.ReactNode;
}
/** One choice of a group (message priority); stays open like the check items. */
export function ComposerToolbarMenuRadioItem({ checked, onSelect, testId, children }: RadioItemProps) {
return (
<button
type="button"
role="menuitemradio"
aria-checked={checked}
onClick={onSelect}
data-testid={testId}
className="flex w-full items-center gap-2 rounded-sm px-3 py-1.5 text-start text-sm hover:bg-accent hover:text-accent-foreground"
>
<span
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center rounded-full border",
checked ? "border-primary" : "border-muted-foreground/50"
)}
aria-hidden="true"
>
{checked && <span className="h-2 w-2 rounded-full bg-primary" />}
</span>
<span className="flex-1">{children}</span>
</button>
);
}
interface PluginItemsProps {
/** A surface of kind 'action' in lib/plugin-slot-catalog.ts. */
surface: string;
/** What the click is about; a function is read at click time. */
context: Record<string, unknown> | (() => Record<string, unknown>);
/** Called before the plugin gets the click, e.g. to close the menu. */
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);
if (actions.length === 0) return null;
return (
<>
{actions.map((action) => {
const Icon = iconForName(action.icon) ?? Puzzle;
const tip = action.shortcut ? `${action.label} (${action.shortcut})` : action.label;
return (
<Button
key={`${action.pluginId}:${action.id}`}
type="button"
variant="ghost"
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}
data-plugin-action={`${action.pluginId}:${action.id}`}
onClick={() => { void runAction(action, surface, context()); }}
>
<Icon className="w-4 h-4" />
<span className="text-sm">{action.label}</span>
</Button>
);
})}
</>
);
}
/** 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, 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;
if (action.checked !== undefined) {
// An option like the menu's own (plain text, read receipt): stays
// open, so several can be set in one go.
return (
<ComposerToolbarMenuCheckItem
key={`${action.pluginId}:${action.id}`}
checked={action.checked}
description={action.description}
onToggle={() => { void runAction(action, surface, typeof context === "function" ? context() : context); }}
>
{action.label}
</ComposerToolbarMenuCheckItem>
);
}
return (
<button
key={`${action.pluginId}:${action.id}`}
type="button"
role="menuitem"
title={action.pluginName}
onClick={() => {
onSelect?.();
void runAction(action, surface, typeof context === "function" ? context() : context);
}}
className={cn(
"flex w-full items-center gap-2 rounded-sm px-3 py-2 text-start text-sm hover:bg-accent hover:text-accent-foreground",
action.danger && "text-destructive",
)}
>
<Icon className="w-4 h-4 shrink-0 text-muted-foreground" />
<span className="flex-1">
<span className="block">{action.label}</span>
{action.description && <span className="block text-xs text-muted-foreground">{action.description}</span>}
</span>
</button>
);
})}
</NarrowOnly>
);
}