mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
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:
@@ -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" />);
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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)'),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user