Files
webmail/components/plugins/app-side-panel.tsx
T
Linus Rath ad36f8d382 fix: show the side panel only where the plugin does, and keep it steady
- The button and the panel of Calendar, Contacts, Files and Settings
  show only when the plugin's shouldShow says yes, so a plugin switched
  off for these views (admin, user or no service) leaves no button that
  opens an empty panel, and no extra rows in Contacts and Settings.
- Files: while the details are open the panel steps aside there only;
  the other views keep it as the user left it. With both stored open the
  details fold when the page loads.
- Resizing stops where the view keeps its 480 px, so a drag or the arrow
  keys no longer fold the panel away (and the handle keeps the focus).
- Settings and Contacts render the same element with or without the
  panel, so a form keeps what was typed when the panel starts docking.
- Contacts on a phone: the panel button sits over an open contact too.
- The panel buttons of the reader and the views have one name, "Plugin
  panel", with aria-pressed, and name the plugin's panel shortcut in
  their title; the open panels are named landmarks.

The reader tests look the button up by its new name and pressed state
(viewer-toolbar-over-side-panel, reader-plugin-surfaces,
viewer-plugin-panel-width), since "Show/Hide plugin panel" is gone.
2026-11-02 23:54:53 +01:00

368 lines
16 KiB
TypeScript

'use client';
// The plugin side panel of Calendar, Contacts, Files and Settings (slot
// app-side-panel), built like the reader's (email-viewer.tsx): one button at
// the end of the view's h-14 toolbar row shows and hides it, it can be
// dragged between 200 and 500 px, and it lies over the view instead of
// squeezing it when the view would get too narrow. Its width and whether it
// is folded are kept in localStorage for all four views. Until the user
// folds or opens it, a plugin that asked to start folded starts folded.
// Only a plugin whose shouldShow says yes gets the button and the panel.
//
// Each view mounts its own panel, so the frame starts over when the user
// switches views; the plugin keeps what it needs in its storage. In the Pro
// shell's panes there is none (only a Mail pane's reader panel).
//
// Phones, and Settings below 1024 px (phone-shaped there), have no room for
// a panel: a button in the phone header (PhoneHeaderActions, and Contacts'
// row over an open contact) asks the plugin for its dialog instead, through
// its onAction hook (surface `app-side-panel`, actionId `open`, context
// { view, context }), and so does api.ui.openPanel there.
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';
import { useTranslations } from 'next-intl';
import { PanelRightClose, PanelRightOpen } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { IconButton } from '@/components/ui/icon-button';
import { ResizeHandle } from '@/components/layout/resize-handle';
import { PluginSlot } from '@/components/plugins/plugin-slot';
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
import { useIsEmbedded } from '@/hooks/use-is-embedded';
import { useIsMobile } from '@/hooks/use-media-query';
import { onPanelRequest } from '@/lib/plugin-sandbox/panel-requests';
import { runPluginAction } from '@/lib/plugin-sandbox/actions';
import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
import { slotPropsFor } from '@/lib/plugin-sandbox/permissions';
import { shortcutKeysFor } from '@/lib/plugin-sandbox/shortcuts';
import type { AppSidePanelContext, AppSidePanelViewName } from '@/lib/plugin-sandbox/assistant/f.types';
import { cn } from '@/lib/utils';
export const APP_SIDE_PANEL_SLOT = 'app-side-panel';
export const APP_SIDE_PANEL_MIN_WIDTH = 200;
export const APP_SIDE_PANEL_MAX_WIDTH = 500;
export const APP_SIDE_PANEL_DEFAULT_WIDTH = 280;
/** The view keeps at least this much room next to a docked panel; with less the panel lies over it. */
export const APP_SIDE_PANEL_MIN_CONTENT = 480;
export const APP_SIDE_PANEL_COLLAPSED_KEY = 'appSidePanelCollapsed';
export const APP_SIDE_PANEL_WIDTH_KEY = 'appSidePanelWidth';
/** The id of a plugin shortcut that opens and closes its panel; its keys go into the button's title. */
export const PANEL_SHORTCUT_ID = 'toggle-panel';
const clampWidth = (n: number) => Math.max(APP_SIDE_PANEL_MIN_WIDTH, Math.min(APP_SIDE_PANEL_MAX_WIDTH, n));
/**
* The button that shows and hides a plugin side panel, the same in the
* reader and in every other view: at the end of the toolbar row above the
* panel. One name ("Plugin panel"); aria-pressed says whether it is open.
* `shortcut`: the keys of the plugin's own shortcut for its panel.
*/
export function PluginPanelToggle({ open, onClick, className, buttonRef, shortcut }: {
open: boolean;
onClick: () => void;
className?: string;
buttonRef?: React.Ref<HTMLButtonElement>;
shortcut?: string | null;
}) {
const t = useTranslations('email_viewer');
const label = t('plugin_panel');
return (
<Button
ref={buttonRef}
variant="ghost"
size="icon"
onClick={onClick}
className={cn('h-8 w-8 shrink-0', className)}
title={shortcut ? `${label} (${shortcut})` : label}
aria-label={label}
aria-pressed={open}
aria-keyshortcuts={shortcut ?? undefined}
data-plugin-panel-toggle=""
>
{open ? <PanelRightClose className="w-4 h-4 text-muted-foreground" /> : <PanelRightOpen className="w-4 h-4 text-muted-foreground" />}
</Button>
);
}
/** The keys of the panel shortcut of the first of these plugins that has one ("Alt+A"), or null. */
export function panelShortcutOf(pluginIds: string[]): string | null {
for (const id of pluginIds) {
const keys = shortcutKeysFor(id, PANEL_SHORTCUT_ID);
if (keys) return keys;
}
return null;
}
/** The panel's name for screen readers: its plugins' names, else "Plugin panel". */
export function panelLabelOf(pluginIds: string[], fallback: string): string {
const names = pluginIds.map((id) => getActivePlugin(id)?.plugin.name).filter((n): n is string => !!n);
return names.length ? names.join(', ') : fallback;
}
// ─── Where the panel can't dock: the plugin's dialog ───────────────
export interface AppSidePanelDialogEntry {
pluginId: string;
view: AppSidePanelViewName;
context: AppSidePanelContext;
}
let dialogEntry: AppSidePanelDialogEntry | null = null;
const dialogListeners = new Set<() => void>();
function setDialogEntry(entry: AppSidePanelDialogEntry | null) {
dialogEntry = entry;
for (const listener of dialogListeners) listener();
}
function subscribeDialogEntry(listener: () => void) {
dialogListeners.add(listener);
return () => { dialogListeners.delete(listener); };
}
/** The view whose plugin panel opens as a dialog right now (a phone), or null. */
export function useAppSidePanelDialogEntry(): AppSidePanelDialogEntry | null {
return useSyncExternalStore(subscribeDialogEntry, () => dialogEntry, () => null);
}
/** Asks the plugin for its dialog with what the view shows (the context is cut to its permissions on the way). */
export function openAppSidePanelDialog(entry: AppSidePanelDialogEntry, pluginId = entry.pluginId): void {
void runPluginAction(pluginId, APP_SIDE_PANEL_SLOT, 'open', { view: entry.view, context: entry.context }).catch(() => {});
}
/** The phone's button for the view's plugin panel, which opens as a dialog there. */
export function AppSidePanelPhoneButton({ className }: { className?: string } = {}) {
const t = useTranslations('email_viewer');
const entry = useAppSidePanelDialogEntry();
if (!entry) return null;
return (
<IconButton
size="icon-lg"
label={t('plugin_panel_show')}
onClick={() => openAppSidePanelDialog(entry)}
className={className}
data-app-side-panel-phone=""
>
<PanelRightOpen />
</IconButton>
);
}
// ─── Which plugins show it ─────────────────────────────────────────
/**
* The plugins whose offer of the panel shows here: each one's shouldShow
* (asked in its background frame with the props it may see) said yes, or it
* has none. Until a plugin answers its offer doesn't count, so a plugin that
* is switched off for these views never gets a button that opens nothing.
*/
function useShownPluginIds(pluginOffers: readonly { pluginId: string; hasShouldShow: boolean }[], view: AppSidePanelViewName, context: AppSidePanelContext): string[] {
const [answers, setAnswers] = useState<Record<string, boolean>>({});
const key = pluginOffers.map((o) => `${o.pluginId}:${o.hasShouldShow ? 1 : 0}`).join(',');
useEffect(() => {
let live = true;
for (const offer of pluginOffers) {
const active = getActivePlugin(offer.pluginId);
if (!offer.hasShouldShow || !active?.background?.evaluateShouldShow) {
setAnswers((prev) => (prev[offer.pluginId] === true ? prev : { ...prev, [offer.pluginId]: true }));
continue;
}
const props = slotPropsFor(active.plugin, APP_SIDE_PANEL_SLOT, { view, context });
active.background.evaluateShouldShow(APP_SIDE_PANEL_SLOT, props)
.then((show) => { if (live) setAnswers((prev) => (prev[offer.pluginId] === show ? prev : { ...prev, [offer.pluginId]: show })); })
.catch(() => { if (live) setAnswers((prev) => ({ ...prev, [offer.pluginId]: false })); });
}
return () => { live = false; };
// The offers by their key; a new context asks again (the plugin may decide by it).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, view, context]);
return useMemo(() => pluginOffers.filter((o) => answers[o.pluginId] === true).map((o) => o.pluginId), [pluginOffers, answers]);
}
// ─── The panel ─────────────────────────────────────────────────────
export interface UseAppSidePanelOptions {
view: AppSidePanelViewName;
/** What the view shows; keep it memoised, a new object is sent to the plugin. */
context: AppSidePanelContext;
/** False where the view has no room for a panel at any width it is in now (Settings below 1024 px): the dialog then. */
dock?: boolean;
/**
* Folded here for now without changing the stored choice (Files while its
* details panel is open), so the other views keep the panel as the user left it.
*/
hidden?: boolean;
/** Called when the panel opens, so the view can fold a panel it shares the place with (Files' details). */
onOpen?: () => void;
}
export interface AppSidePanel {
/** A plugin shows the panel here (outside the Pro shell's panes). */
offered: boolean;
/** The panel docks in this view now (else it is a dialog, or nothing). */
docks: boolean;
/** Whether the docked panel is open. */
open: boolean;
/** Whether the user's choice is to have it open (also while `hidden` folds it here). */
wantsOpen: boolean;
/** The button for the end of the toolbar row, or null. */
toggle: ReactNode;
/** The resize handle and the panel, as the last children of the row it sits in; null while folded. */
panel: ReactNode;
/** Put on the row the panel sits in: its width decides docked or over the view. */
rowRef: (el: HTMLElement | null) => void;
/** Folds the panel and keeps it folded. */
close: () => void;
}
function readChoice(): boolean | null {
if (typeof window === 'undefined') return null;
try {
const stored = localStorage.getItem(APP_SIDE_PANEL_COLLAPSED_KEY);
return stored === '1' ? true : stored === '0' ? false : null;
} catch { return null; }
}
function readWidth(): number {
if (typeof window === 'undefined') return APP_SIDE_PANEL_DEFAULT_WIDTH;
try {
const n = parseInt(localStorage.getItem(APP_SIDE_PANEL_WIDTH_KEY) ?? '', 10);
return Number.isFinite(n) ? clampWidth(n) : APP_SIDE_PANEL_DEFAULT_WIDTH;
} catch { return APP_SIDE_PANEL_DEFAULT_WIDTH; }
}
export function useAppSidePanel({ view, context, dock = true, hidden = false, onOpen }: UseAppSidePanelOptions): AppSidePanel {
const t = useTranslations('email_viewer');
const isEmbedded = useIsEmbedded();
const isMobile = useIsMobile();
const offers = usePluginSlotOffers(APP_SIDE_PANEL_SLOT);
const shownIds = useShownPluginIds(isEmbedded ? [] : offers, view, context);
const offered = shownIds.length > 0 && !isEmbedded;
const docks = offered && dock && !isMobile;
const asDialog = offered && !docks;
const [choice, setChoice] = useState<boolean | null>(readChoice);
const [width, setWidth] = useState<number>(readWidth);
const widthAtStart = useRef(width);
const [forcedOpen, setForcedOpen] = useState(false);
const [focusRequest, setFocusRequest] = useState(0);
const [rowEl, setRowEl] = useState<HTMLElement | null>(null);
const [rowWidth, setRowWidth] = useState(0);
const toggleRef = useRef<HTMLButtonElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!docks || !rowEl || typeof ResizeObserver === 'undefined') return;
const ro = new ResizeObserver((entries) => setRowWidth(Math.round(entries[0]?.contentRect.width ?? 0)));
ro.observe(rowEl);
return () => ro.disconnect();
}, [docks, rowEl]);
const collapsed = choice ?? offers.some((o) => o.startFolded && shownIds.includes(o.pluginId));
const tooNarrow = rowWidth > 0 && rowWidth - width < APP_SIDE_PANEL_MIN_CONTENT;
const folded = collapsed || hidden || (tooNarrow && !forcedOpen);
const overlay = !folded && tooNarrow;
// Docked, the panel grows only as far as the view keeps its room: a drag
// or the arrow keys stop there instead of folding the panel away.
const dockedMax = rowWidth > 0 ? Math.max(APP_SIDE_PANEL_MIN_WIDTH, rowWidth - APP_SIDE_PANEL_MIN_CONTENT) : APP_SIDE_PANEL_MAX_WIDTH;
useEffect(() => {
if (choice === null) return;
try { localStorage.setItem(APP_SIDE_PANEL_COLLAPSED_KEY, choice ? '1' : '0'); } catch { /* ignore */ }
}, [choice]);
useEffect(() => {
try { localStorage.setItem(APP_SIDE_PANEL_WIDTH_KEY, String(width)); } catch { /* ignore */ }
}, [width]);
const onOpenRef = useRef(onOpen);
onOpenRef.current = onOpen;
const state = useRef({ offered, docks, folded, tooNarrow, view, context, pluginId: shownIds[0] ?? null });
state.current = { offered, docks, folded, tooNarrow, view, context, pluginId: shownIds[0] ?? null };
const setOpen = useCallback((want: boolean) => {
const now = state.current;
if (want) {
setChoice(false);
if (now.tooNarrow) setForcedOpen(true);
// Opening on request puts the focus in the panel (the plugin's input).
setFocusRequest((n) => n + 1);
onOpenRef.current?.();
} else {
// Focus in the panel goes back to the button, not to the page's start.
const hadFocus = !!panelRef.current && panelRef.current.contains(document.activeElement);
setChoice(true);
setForcedOpen(false);
if (hadFocus) requestAnimationFrame(() => toggleRef.current?.focus());
}
}, []);
const close = useCallback(() => {
if (!state.current.folded) setOpen(false);
}, [setOpen]);
// api.ui.openPanel / closePanel / togglePanel for this slot.
useEffect(() => onPanelRequest((request) => {
if (request.slot !== APP_SIDE_PANEL_SLOT) return null;
const now = state.current;
if (!now.offered) return null;
if (!now.docks) {
// No room for a panel: the plugin that asked gets the open request
// for its dialog, and the panel itself stays closed.
if (request.mode !== 'close') openAppSidePanelDialog({ pluginId: request.pluginId, view: now.view, context: now.context });
return false;
}
const want = request.mode === 'open' ? true : request.mode === 'close' ? false : now.folded;
setOpen(want);
return want;
}), [setOpen]);
// The phone button, while this view's panel is a dialog.
const firstPluginId = shownIds[0] ?? null;
useEffect(() => {
if (!asDialog || !firstPluginId) return;
setDialogEntry({ pluginId: firstPluginId, view, context });
return () => setDialogEntry(null);
}, [asDialog, firstPluginId, view, context]);
const toggle = docks ? (
<PluginPanelToggle open={!folded} onClick={() => setOpen(folded)} buttonRef={toggleRef} shortcut={panelShortcutOf(shownIds)} />
) : null;
const panel = docks && !folded ? (
<>
{!overlay && (
<ResizeHandle
pane="sidePanel"
value={width}
min={APP_SIDE_PANEL_MIN_WIDTH}
max={Math.min(APP_SIDE_PANEL_MAX_WIDTH, dockedMax)}
onResizeStart={() => { widthAtStart.current = width; }}
// The panel is on the right: dragging left makes it wider.
onResize={(delta) => setWidth(clampWidth(Math.min(dockedMax, widthAtStart.current - delta)))}
onResizeEnd={() => { widthAtStart.current = width; }}
onDoubleClick={() => setWidth(Math.min(dockedMax, APP_SIDE_PANEL_DEFAULT_WIDTH))}
/>
)}
<div
ref={panelRef}
role="complementary"
aria-label={panelLabelOf(shownIds, t('plugin_panel'))}
data-app-side-panel={overlay ? 'overlay' : 'docked'}
data-app-side-panel-view={view}
className={cn(
'flex flex-col h-full border-s border-border bg-background overflow-hidden shrink-0',
overlay && 'absolute inset-y-0 end-0 z-30 shadow-xl',
)}
style={overlay
? { width: Math.min(width, Math.max(APP_SIDE_PANEL_MIN_WIDTH, rowWidth - 48)) }
: { width, minWidth: width }}
>
<div className="flex-1 min-h-0 overflow-y-auto">
<PluginSlot name="app-side-panel" extraProps={{ view, context, focusRequest }} />
</div>
</div>
</>
) : null;
return { offered, docks, open: docks && !folded, wantsOpen: docks && !collapsed, toggle, panel, rowRef: setRowEl, close };
}