From d65cdba18139f8ab5610ed2e12180aa3b33a0b4a Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 04:12:58 +0100
Subject: [PATCH] fix: put the Apps dialog on the shared dialog shell, with a
named close button and a backdrop that closes it
---
.../__tests__/sidebar-apps-modal.test.tsx | 40 +++++++++
components/layout/sidebar-apps-modal.tsx | 89 +++++++------------
2 files changed, 74 insertions(+), 55 deletions(-)
create mode 100644 components/layout/__tests__/sidebar-apps-modal.test.tsx
diff --git a/components/layout/__tests__/sidebar-apps-modal.test.tsx b/components/layout/__tests__/sidebar-apps-modal.test.tsx
new file mode 100644
index 000000000..b39e09c6d
--- /dev/null
+++ b/components/layout/__tests__/sidebar-apps-modal.test.tsx
@@ -0,0 +1,40 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+// The "Apps" modal on the shared dialog shell: a named close button, a
+// backdrop that closes it, Escape from anywhere inside, named row buttons.
+
+vi.mock('@/hooks/use-resolved-sidebar-apps', () => ({ useManagedSidebarApps: () => [] }));
+
+const { SidebarAppsModal } = await import('../sidebar-apps-modal');
+const { useSettingsStore } = await import('@/stores/settings-store');
+
+describe('apps modal', () => {
+ it('closes from its named X and from the backdrop', () => {
+ const onClose = vi.fn();
+ render();
+ fireEvent.click(screen.getByRole('button', { name: 'close' }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+
+ const backdrop = screen.getByRole('dialog').parentElement!;
+ fireEvent.mouseDown(backdrop);
+ fireEvent.click(backdrop);
+ expect(onClose).toHaveBeenCalledTimes(2);
+ });
+
+ it('closes on Escape after a click on its heading', () => {
+ const onClose = vi.fn();
+ render();
+ const heading = screen.getByRole('heading', { name: 'modal_title' });
+ fireEvent.mouseDown(heading);
+ fireEvent.keyDown(heading, { key: 'Escape' });
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('names the edit and delete buttons of each app', () => {
+ useSettingsStore.setState({ sidebarApps: [{ id: 'a', name: 'Wiki', url: 'https://wiki.example', icon: 'Globe', openMode: 'tab' }] } as never);
+ render();
+ expect(screen.getByRole('button', { name: 'edit_app' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'delete' })).toBeInTheDocument();
+ });
+});
diff --git a/components/layout/sidebar-apps-modal.tsx b/components/layout/sidebar-apps-modal.tsx
index cd1c0b0fa..a9d8e7043 100644
--- a/components/layout/sidebar-apps-modal.tsx
+++ b/components/layout/sidebar-apps-modal.tsx
@@ -2,15 +2,16 @@
import { useState, useCallback } from 'react';
import { useTranslations } from 'next-intl';
-import { X, Plus, Pencil, Trash2, ExternalLink, PanelRight, Lock } from '@/components/icons';
+import { Plus, Pencil, Trash2, ExternalLink, PanelRight, Lock } from '@/components/icons';
import { iconForName } from '@/components/icons';
import { cn, generateUUID } from '@/lib/utils';
import { Button } from '@/components/ui/button';
+import { IconButton } from '@/components/ui/icon-button';
import { Input } from '@/components/ui/input';
import { IconPicker } from './icon-picker';
import { useSettingsStore, type SidebarApp } from '@/stores/settings-store';
import { useManagedSidebarApps } from '@/hooks/use-resolved-sidebar-apps';
-import { useFocusTrap } from '@/hooks/use-focus-trap';
+import { Dialog } from '@/components/ui/dialog';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
@@ -240,18 +241,17 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
const [isCreating, setIsCreating] = useState(false);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
- const modalRef = useFocusTrap({
- isActive: isOpen,
- onEscape: () => {
- if (isCreating || editingId) {
- setIsCreating(false);
- setEditingId(null);
- } else {
- onClose();
- }
- },
- restoreFocus: true,
- });
+ // Escape, the X and an outside click leave an open app form first, then
+ // the dialog; a click outside is ignored while a form is open.
+ const editing = isCreating || !!editingId;
+ const handleClose = useCallback(() => {
+ if (editing) {
+ setIsCreating(false);
+ setEditingId(null);
+ } else {
+ onClose();
+ }
+ }, [editing, onClose]);
const handleCreate = useCallback((data: SidebarAppFormData) => {
const id = `app-${generateUUID()}`;
@@ -275,36 +275,17 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
removeSidebarApp(app.id);
}, [removeSidebarApp, confirmDialog, t]);
- if (!isOpen) return null;
-
return (
-
-
+
)}
-
-
+
-
+ >
);
}