Files
webmail/components/plugins/plugin-sidebar-sections.tsx
T

96 lines
3.8 KiB
TypeScript

'use client';
import { useId, useMemo, useState, useSyncExternalStore } from 'react';
import { ChevronDown, ChevronRight } from '@/components/icons';
import { get as getActivePlugin, offersForSlot, subscribe } from '@/lib/plugin-sandbox/registry';
import { sectionTitle, subscribeSectionTitles } from '@/lib/plugin-sandbox/section-titles';
import { PluginIframeSlot } from './plugin-iframe-slot';
const STORAGE_PREFIX = 'plugin-sidebar-section-collapsed:';
function readCollapsed(pluginId: string): boolean {
try {
return localStorage.getItem(STORAGE_PREFIX + pluginId) === '1';
} catch {
return false;
}
}
function writeCollapsed(pluginId: string, collapsed: boolean): void {
try {
if (collapsed) localStorage.setItem(STORAGE_PREFIX + pluginId, '1');
else localStorage.removeItem(STORAGE_PREFIX + pluginId);
} catch {
// Storage may be blocked; the section just opens expanded next time.
}
}
function PluginSidebarSection({ pluginId, extraProps }: { pluginId: string; extraProps: Record<string, unknown> }) {
const [collapsed, setCollapsed] = useState(() => readCollapsed(pluginId));
const bodyId = useId();
const title = useSyncExternalStore(subscribeSectionTitles, () => sectionTitle(pluginId), () => undefined);
const name = title || getActivePlugin(pluginId)?.plugin.name || pluginId;
const hasActions = !!getActivePlugin(pluginId)?.slotOffers.some((o) => o.name === 'sidebar-section-actions');
const toggle = () => {
setCollapsed((prev) => {
writeCollapsed(pluginId, !prev);
return !prev;
});
};
return (
<div data-plugin-sidebar-section={pluginId}>
{/* A separate action slot keeps buttons aligned with the host-owned
heading. Never put interactive controls inside the collapse button. */}
<div className="flex w-full items-end pb-1 pt-5 px-3 min-w-0">
<button
type="button"
onClick={toggle}
aria-expanded={!collapsed}
aria-controls={bodyId}
className="group flex flex-1 min-w-0 items-center select-none rounded-sm hover:bg-muted/40 transition-colors"
>
{collapsed ? (
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0 rtl:-scale-x-100" />
) : (
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
)}
<span className="ms-1.5 text-sm font-semibold text-foreground truncate">{name}</span>
</button>
{hasActions && !collapsed && (
<div data-plugin-sidebar-actions={pluginId} className="ms-auto shrink-0 ps-1 flex items-center">
<PluginIframeSlot pluginId={pluginId} slot="sidebar-section-actions" />
</div>
)}
</div>
{/* A collapsed section has no iframe at all, so it costs nothing. */}
<div id={bodyId} hidden={collapsed}>
{!collapsed && <PluginIframeSlot pluginId={pluginId} slot="sidebar-section" extraProps={extraProps} />}
</div>
</div>
);
}
/**
* The `sidebar-section` slot: one collapsible section per plugin in the mail
* sidebar, below the folders and tags. The header carries the title the plugin
* set (api.ui.setSectionTitle), or its name;
* whether it is folded is remembered per plugin in this browser.
*/
export function PluginSidebarSections({ mailboxId }: { mailboxId: string | null }) {
const getSnapshot = () => offersForSlot('sidebar-section');
const offers = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const extraProps = useMemo(() => ({ mailboxId }), [mailboxId]);
if (offers.length === 0) return null;
return (
<div data-plugin-slot="sidebar-section">
{offers.map((offer) => (
<PluginSidebarSection key={offer.pluginId} pluginId={offer.pluginId} extraProps={extraProps} />
))}
</div>
);
}