Merge pull request #1317 from LeonardNJU/feat/plugin-sidebar-heading-actions

feat(plugins): allow inline actions in collapsible sidebar headers
This commit is contained in:
Linus Rath
2026-10-24 17:54:53 +02:00
committed by GitHub
3 changed files with 44 additions and 16 deletions
@@ -31,7 +31,7 @@ const mailboxes: Mailbox[] = [
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, totalEmails: 0, unreadEmails: 0, myRights: { mayAddItems: true } },
] as unknown as Mailbox[];
function offer(id: string, name: string, slot: string) {
function offer(id: string, name: string, slot: string, extraSlots: string[] = []) {
const plugin: InstalledPlugin = {
id, name, version: '1.0.0', author: '', description: '', type: 'ui-extension',
permissions: ['ui:sidebar-widget'], grantedPermissions: ['ui:sidebar-widget'], entrypoint: 'index.js',
@@ -42,7 +42,7 @@ function offer(id: string, name: string, slot: string) {
code: '',
tier: 'untrusted',
background: { evaluateShouldShow: vi.fn(async () => true) } as never,
slotOffers: [{ name: slot as never, order: 100, hasShouldShow: false }],
slotOffers: [{ name: slot as never, order: 100, hasShouldShow: false }, ...extraSlots.map((name) => ({ name: name as never, order: 100, hasShouldShow: false }))],
hookDisposables: [],
});
}
@@ -84,6 +84,24 @@ describe('Sidebar plugin sections', () => {
expect(opts.extraProps).toEqual({ mailboxId: 'inbox' });
});
it('places inline plugin actions next to the collapsible heading, never inside its button', async () => {
offer('cal', 'Agenda', 'sidebar-section', ['sidebar-section-actions']);
const { container } = render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
await waitFor(() => expect(createSlotInstance).toHaveBeenCalledTimes(2));
const section = container.querySelector('[data-plugin-sidebar-section="cal"]')!;
const header = screen.getByRole('button', { name: 'Agenda' });
const actions = section.querySelector('[data-plugin-sidebar-actions="cal"]')!;
expect(actions).toBeInTheDocument();
expect(header.contains(actions)).toBe(false);
expect(header.parentElement).toBe(actions.parentElement);
expect(section.querySelector('[data-plugin-iframe-slot="cal:sidebar-section-actions"]')).toBeInTheDocument();
fireEvent.click(header);
expect(header).toHaveAttribute('aria-expanded', 'false');
expect(section.querySelector('[data-plugin-sidebar-actions="cal"]')).toBeNull();
});
it('folds a section and remembers it per plugin', async () => {
offer('cal', 'Agenda', 'sidebar-section');
const first = render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
+23 -14
View File
@@ -30,6 +30,7 @@ function PluginSidebarSection({ pluginId, extraProps }: { pluginId: string; extr
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) => {
@@ -40,21 +41,29 @@ function PluginSidebarSection({ pluginId, extraProps }: { pluginId: string; extr
return (
<div data-plugin-sidebar-section={pluginId}>
{/* Same look as the sidebar's own section headers (Folders, Tags). */}
<button
type="button"
onClick={toggle}
aria-expanded={!collapsed}
aria-controls={bodyId}
className="group w-full flex items-center pb-1 pt-5 px-3 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" />
{/* 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>
)}
<span className="ms-1.5 text-sm font-semibold text-foreground truncate">{name}</span>
</button>
</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} />}
+1
View File
@@ -51,6 +51,7 @@ export const SLOT_CATALOG = {
'navigation-rail-bottom': S('navigation', 'frame', ['ui:navigation-rail'], {}, 'the bottom of the navigation rail'),
'sidebar-widget': S('navigation', 'frame', ['ui:sidebar-widget'], {}, 'a widget at the bottom of the mail sidebar'),
'sidebar-section': S('navigation', 'frame', ['ui:sidebar-widget'], { mailboxId: null }, 'a collapsible section in the mail sidebar, below the folders'),
'sidebar-section-actions': S('navigation', 'frame', ['ui:sidebar-widget'], {}, 'compact controls beside a collapsible plugin sidebar-section heading', true),
'top-bar-actions': S('navigation', 'action', ['ui:toolbar'], {}, 'buttons in the top bar, next to search', true),
'app-page': S('navigation', 'frame', ['ui:app-page'], { pageId: null, path: null }, 'a full page of its own, with an entry in the navigation rail (api.ui.setPages)'),